Compare commits

..

12 Commits

Author SHA1 Message Date
dba0d3cd62 refactor: move custom adapter to providers
All checks were successful
Build and Deploy Angular App / build (push) Successful in 48s
2024-08-26 02:16:37 +03:00
7a9bca86bc build: update package 2024-08-26 02:15:48 +03:00
f24c1fd9c8 fix: remove double start token refresh 2024-08-26 02:13:27 +03:00
c945a1016b fix: don't try update if token is not found 2024-08-26 02:12:45 +03:00
8a584fd28a feat: try refreshing if error not related to 401 or 403 error 2024-08-26 02:08:51 +03:00
60d306f9c9 fix: unsubscribe from listening to refresh token 2024-08-26 01:24:57 +03:00
5d79d86c44 build: remove package 2024-08-24 04:29:06 +03:00
eada16110b refactor: clean code 2024-08-24 04:28:53 +03:00
b215d8909c refactor: remove log 2024-08-24 04:28:23 +03:00
1f03c2a9c3 refactor: refresh token
The service did not update tokens well, so it was rewritten
2024-08-24 04:27:13 +03:00
48a74ecbf5 refactor: remove log 2024-08-24 04:25:43 +03:00
fd5a1cb14f fix: delete saving states
Parallel use of the same Api Service instance is likely to replace requestData
2024-08-24 04:24:44 +03:00
19 changed files with 291 additions and 239 deletions

35
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.0.0-b2", "version": "1.0.0-b3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "1.0.0-b2", "version": "1.0.0-b3",
"dependencies": { "dependencies": {
"@angular/animations": "^18.2.1", "@angular/animations": "^18.2.1",
"@angular/cdk": "~18.2.1", "@angular/cdk": "~18.2.1",
@ -19,9 +19,10 @@
"@angular/platform-browser": "^18.2.1", "@angular/platform-browser": "^18.2.1",
"@angular/platform-browser-dynamic": "^18.2.1", "@angular/platform-browser-dynamic": "^18.2.1",
"@angular/router": "^18.2.1", "@angular/router": "^18.2.1",
"@dhutaryan/ngx-mat-timepicker": "^18.0.1",
"@progress/kendo-date-math": "^1.5.13", "@progress/kendo-date-math": "^1.5.13",
"rxjs": "~7.8.1", "rxjs": "~7.8.1",
"tslib": "^2.6.3", "tslib": "^2.7.0",
"zone.js": "^0.14.10" "zone.js": "^0.14.10"
}, },
"devDependencies": { "devDependencies": {
@ -35,7 +36,6 @@
"karma-coverage": "~2.2.1", "karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"terser-webpack-plugin": "^5.3.10",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
}, },
@ -232,6 +232,13 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@angular-devkit/build-angular/node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
"dev": true,
"license": "0BSD"
},
"node_modules/@angular-devkit/build-webpack": { "node_modules/@angular-devkit/build-webpack": {
"version": "0.1802.1", "version": "0.1802.1",
"resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.1.tgz", "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.1.tgz",
@ -2528,6 +2535,20 @@
"node": ">=0.1.90" "node": ">=0.1.90"
} }
}, },
"node_modules/@dhutaryan/ngx-mat-timepicker": {
"version": "18.0.1",
"resolved": "https://registry.npmjs.org/@dhutaryan/ngx-mat-timepicker/-/ngx-mat-timepicker-18.0.1.tgz",
"integrity": "sha512-7qOiDFGorfmJJ/f9In4jHRJq8sDZqtX0aBFQU/KEGkBywmm0In7C+Z7To1DWM5hbR/XAC4d6b+sOqq0Pcqqsmw==",
"license": "MIT",
"dependencies": {
"tslib": ">=2.0.0"
},
"peerDependencies": {
"@angular/common": ">=18.0.0",
"@angular/core": ">=18.0.0",
"@angular/material": ">=18.0.0"
}
},
"node_modules/@discoveryjs/json-ext": { "node_modules/@discoveryjs/json-ext": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.1.tgz", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.1.tgz",
@ -12544,9 +12565,9 @@
} }
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.6.3", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tuf-js": { "node_modules/tuf-js": {

View File

@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.0.0-b2", "version": "1.0.0-b3",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
@ -21,9 +21,10 @@
"@angular/platform-browser": "^18.2.1", "@angular/platform-browser": "^18.2.1",
"@angular/platform-browser-dynamic": "^18.2.1", "@angular/platform-browser-dynamic": "^18.2.1",
"@angular/router": "^18.2.1", "@angular/router": "^18.2.1",
"@dhutaryan/ngx-mat-timepicker": "^18.0.1",
"@progress/kendo-date-math": "^1.5.13", "@progress/kendo-date-math": "^1.5.13",
"rxjs": "~7.8.1", "rxjs": "~7.8.1",
"tslib": "^2.6.3", "tslib": "^2.7.0",
"zone.js": "^0.14.10" "zone.js": "^0.14.10"
}, },
"devDependencies": { "devDependencies": {
@ -37,7 +38,6 @@
"karma-coverage": "~2.2.1", "karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"terser-webpack-plugin": "^5.3.10",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
} }

View File

@ -1,9 +1,5 @@
import {HttpHeaders} from "@angular/common/http"; import {HttpHeaders} from "@angular/common/http";
export interface SetRequestBuilderAfterBuild {
setRequestBuilder(request: RequestData): void;
}
export interface RequestData { export interface RequestData {
endpoint: string; endpoint: string;
queryParams: Record<string, string | number | boolean | Array<any> | null> | null; queryParams: Record<string, string | number | boolean | Array<any> | null> | null;
@ -11,6 +7,7 @@ export interface RequestData {
data: any; data: any;
silenceMode: boolean; silenceMode: boolean;
withCredentials: boolean; withCredentials: boolean;
needAuth: boolean;
} }
export class RequestBuilder { export class RequestBuilder {
@ -20,10 +17,8 @@ export class RequestBuilder {
private data: any = null; private data: any = null;
private silenceMode: boolean = false; private silenceMode: boolean = false;
private withCredentials: boolean = false; private withCredentials: boolean = false;
private readonly object: any;
constructor(obj: any) { constructor() {
this.object = obj;
} }
public setEndpoint(endpoint: string): this { public setEndpoint(endpoint: string): this {
@ -58,16 +53,16 @@ export class RequestBuilder {
return this; return this;
} }
public build<Type>(): Type { public get build(): RequestData {
(this.object as SetRequestBuilderAfterBuild).setRequestBuilder({ return {
endpoint: this.endpoint, endpoint: this.endpoint,
queryParams: this.queryParams, queryParams: this.queryParams,
httpHeaders: this.httpHeaders, httpHeaders: this.httpHeaders,
data: this.data, data: this.data,
silenceMode: this.silenceMode, silenceMode: this.silenceMode,
withCredentials: this.withCredentials withCredentials: this.withCredentials,
}); needAuth: false
return this.object as Type; };
} }
public getEndpoint(): string { public getEndpoint(): string {
@ -97,16 +92,8 @@ export class RequestBuilder {
httpHeaders: new HttpHeaders(), httpHeaders: new HttpHeaders(),
data: null, data: null,
silenceMode: false, silenceMode: false,
withCredentials: false withCredentials: false,
needAuth: false
}; };
} }
public reset(): void {
this.endpoint = '';
this.queryParams = null;
this.httpHeaders = new HttpHeaders();
this.data = null;
this.silenceMode = false;
this.withCredentials = false;
}
} }

View File

@ -1,10 +1,10 @@
import {catchError, filter, mergeMap, Observable, retryWhen, switchMap, tap, timer} from "rxjs"; import {catchError, distinctUntilChanged, filter, first, mergeMap, Observable, retryWhen, switchMap, timer} from "rxjs";
import {HttpClient, HttpErrorResponse} from "@angular/common/http"; import {HttpClient, HttpErrorResponse} from "@angular/common/http";
import {NotifyColor, OpenNotifyService} from "@service/open-notify.service"; import {NotifyColor, OpenNotifyService} from "@service/open-notify.service";
import {environment} from "@environment"; import {environment} from "@environment";
import {Router} from "@angular/router"; import {Router} from "@angular/router";
import {Injectable} from "@angular/core"; import {Injectable} from "@angular/core";
import {RequestBuilder, RequestData, SetRequestBuilderAfterBuild} from "@api/RequestBuilder"; import {RequestBuilder, RequestData} from "@api/RequestBuilder";
import {TokenRefreshService} from "@service/token-refresh.service"; import {TokenRefreshService} from "@service/token-refresh.service";
import {AuthToken} from "@service/auth.service"; import {AuthToken} from "@service/auth.service";
@ -36,19 +36,16 @@ export enum AvailableVersion {
} }
@Injectable() @Injectable()
export default abstract class ApiService implements SetRequestBuilderAfterBuild { export default abstract class ApiService {
constructor(private http: HttpClient, private notify: OpenNotifyService, private router: Router, protected tokenRefreshService: TokenRefreshService) { constructor(private http: HttpClient, private notify: OpenNotifyService, private router: Router, protected tokenRefreshService: TokenRefreshService) {
} }
private apiUrl = environment.apiUrl; private apiUrl = environment.apiUrl;
protected abstract basePath: string; protected abstract basePath: string;
protected abstract version: AvailableVersion; protected abstract version: AvailableVersion;
private request: RequestData = RequestBuilder.getStandardRequestData();
public static readonly tokenKey = 'auth_token';
public setRequestBuilder(request: RequestData): void { public static readonly tokenKey = 'auth_token';
this.request = request;
}
private static addQuery(endpoint: string, queryParams?: Record<string, string | number | boolean | Array<any> | null> | null): string { private static addQuery(endpoint: string, queryParams?: Record<string, string | number | boolean | Array<any> | null> | null): string {
const url = new URL(endpoint); const url = new URL(endpoint);
@ -72,73 +69,80 @@ export default abstract class ApiService implements SetRequestBuilderAfterBuild
return parts.map(part => part.replace(/(^\/+|\/+$)/g, '')).join('/'); return parts.map(part => part.replace(/(^\/+|\/+$)/g, '')).join('/');
} }
protected get combinedUrl() { protected combinedUrl(request: RequestData) {
return ApiService.addQuery(ApiService.combineUrls(this.apiUrl, AvailableVersion[this.version], this.basePath, this.request.endpoint), this.request.queryParams); return ApiService.addQuery(ApiService.combineUrls(this.apiUrl, AvailableVersion[this.version], this.basePath, request.endpoint), request.queryParams);
} }
private makeHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put'): Observable<Type> { private sendHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData): Observable<Type> {
const doneEndpoint = this.combinedUrl; const doneEndpoint = this.combinedUrl(request);
return this.tokenRefreshService.getTokenRefreshing$().pipe( return this.http.request<Type>(method, doneEndpoint, {
filter(isRefreshing => !isRefreshing), withCredentials: request.withCredentials,
switchMap(() => headers: request.httpHeaders,
this.http.request<Type>(method, doneEndpoint, { body: request.data
withCredentials: this.request.withCredentials, }).pipe(
headers: this.request.httpHeaders, retryWithInterval<Type>(),
body: this.request.data catchError(error => {
}).pipe( if (!request.silenceMode)
tap(_ => this.request = RequestBuilder.getStandardRequestData()), this.handleError(error);
retryWithInterval<Type>(),
catchError(error => {
if (!this.request.silenceMode)
this.handleError(error);
this.request = RequestBuilder.getStandardRequestData(); throw error;
throw error; })
})
)
)
); );
} }
private makeHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put', request: RequestData): Observable<Type> {
if (request.needAuth)
return this.tokenRefreshService.getTokenRefreshing$().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);
})
);
return this.sendHttpRequest<Type>(method, request);
}
private getRequest(request: RequestData | string | null): RequestData {
if (request === null)
return this.createRequestBuilder().build;
if (typeof request === 'string')
return this.createRequestBuilder().setEndpoint(request as string).build;
return request as RequestData;
}
public createRequestBuilder() { public createRequestBuilder() {
this.request = RequestBuilder.getStandardRequestData(); return new RequestBuilder();
return new RequestBuilder(this);
} }
public get<Type>(endpoint: string = ''): Observable<Type> { public get<Type>(request: RequestData | string | null = null): Observable<Type> {
if (endpoint) return this.makeHttpRequest<Type>('get', this.getRequest(request));
this.request.endpoint = endpoint;
return this.makeHttpRequest<Type>('get');
} }
public post<Type>(endpoint: string = ''): Observable<Type> { public post<Type>(request: RequestData | string | null = null): Observable<Type> {
if (endpoint) return this.makeHttpRequest<Type>('post', this.getRequest(request));
this.request.endpoint = endpoint;
return this.makeHttpRequest<Type>('post');
} }
public put<Type>(endpoint: string = ''): Observable<Type> { public put<Type>(request: RequestData | string | null = null): Observable<Type> {
if (endpoint) return this.makeHttpRequest<Type>('put', this.getRequest(request));
this.request.endpoint = endpoint;
return this.makeHttpRequest<Type>('put');
} }
public delete<Type>(endpoint: string = ''): Observable<Type> { public delete<Type>(request: RequestData | string | null = null): Observable<Type> {
if (endpoint) return this.makeHttpRequest<Type>('delete', this.getRequest(request));
this.request.endpoint = endpoint;
return this.makeHttpRequest<Type>('delete');
} }
public addAuth() { public addAuth(request: RequestData) {
const token = localStorage.getItem(ApiService.tokenKey); request.needAuth = true;
if (!token)
return this;
const authToken = AuthToken.httpHeader((JSON.parse(token) as AuthToken));
authToken.keys().forEach(key => this.request.httpHeaders = this.request.httpHeaders.append(key, authToken.get(key) ?? ''));
return this; return this;
} }

View File

@ -12,26 +12,29 @@ export default class AuthApiService extends ApiService {
public readonly version = AvailableVersion.v1; public readonly version = AvailableVersion.v1;
public login(login: LoginRequest) { public login(login: LoginRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('Login') .setEndpoint('Login')
.setData(login) .setData(login)
.build<ApiService>() .setWithCredentials()
.post<TokenResponse>() .build;
return this.post<TokenResponse>(request)
.pipe( .pipe(
tap(response => { tap(response => {
AuthService.setToken(response, AvailableAuthenticationProvider.Bearer, this.createRequestBuilder().setEndpoint('ReLogin').build<AuthApiService>().combinedUrl); AuthService.setToken(response, AvailableAuthenticationProvider.Bearer, this.combinedUrl(this.createRequestBuilder().setEndpoint('ReLogin').build));
this.tokenRefreshService.startTokenRefresh(response.expiresIn); this.tokenRefreshService.setRefreshTokenExpireMs(response.expiresIn);
}) })
); );
} }
public logout() { public logout() {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setWithCredentials() .setWithCredentials()
.setEndpoint('Logout') .setEndpoint('Logout')
.build<ApiService>() .build;
.addAuth()
.get() return this.addAuth(request)
.get(request)
.pipe( .pipe(
tap(_ => { tap(_ => {
localStorage.removeItem(ApiService.tokenKey); localStorage.removeItem(ApiService.tokenKey);
@ -40,11 +43,13 @@ export default class AuthApiService extends ApiService {
} }
public getRole(isSilence: boolean = true) { public getRole(isSilence: boolean = true) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setSilenceMode(isSilence) .setSilenceMode(isSilence)
.build<ApiService>() .setEndpoint('GetRole')
.addAuth() .build;
.get<AuthRoles>('GetRole')
return this.addAuth(request)
.get<AuthRoles>(request)
.pipe( .pipe(
catchError(_ => { catchError(_ => {
return of(null); return of(null);

View File

@ -8,10 +8,11 @@ export class DisciplineService extends ApiService {
public readonly version = AvailableVersion.v1; public readonly version = AvailableVersion.v1;
public getDisciplines(page: number | null = null, pageSize: number | null = null) { public getDisciplines(page: number | null = null, pageSize: number | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize}) .setQueryParams({page: page, pageSize: pageSize})
.build<ApiService>() .build;
.get<DisciplineResponse[]>();
return this.get<DisciplineResponse[]>(request);
} }
public getById(id: number) { public getById(id: number) {

View File

@ -9,10 +9,11 @@ export class FacultyService extends ApiService {
public readonly version = AvailableVersion.v1; public readonly version = AvailableVersion.v1;
public getFaculties(page: number | null = null, pageSize: number | null = null) { public getFaculties(page: number | null = null, pageSize: number | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize}) .setQueryParams({page: page, pageSize: pageSize})
.build<ApiService>() .build;
.get<FacultyResponse[]>();
return this.get<FacultyResponse[]>(request);
} }
public getById(id: number) { public getById(id: number) {

View File

@ -9,10 +9,11 @@ export class GroupService extends ApiService {
public readonly version = AvailableVersion.v1; public readonly version = AvailableVersion.v1;
public getGroups(page: number | null = null, pageSize: number | null = null) { public getGroups(page: number | null = null, pageSize: number | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize}) .setQueryParams({page: page, pageSize: pageSize})
.build<ApiService>() .build;
.get<GroupResponse[]>();
return this.get<GroupResponse[]>(request);
} }
public getById(id: number) { public getById(id: number) {

View File

@ -8,10 +8,11 @@ export class ProfessorService extends ApiService {
public readonly version = AvailableVersion.v1; public readonly version = AvailableVersion.v1;
public getProfessors(page: number | null = null, pageSize: number | null = null) { public getProfessors(page: number | null = null, pageSize: number | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setQueryParams({page: page, pageSize: pageSize}) .setQueryParams({page: page, pageSize: pageSize})
.build<ApiService>() .build;
.get<ProfessorResponse[]>();
return this.get<ProfessorResponse[]>(request);
} }
public getById(id: number) { public getById(id: number) {

View File

@ -20,41 +20,46 @@ export class ScheduleService extends ApiService {
} }
public postSchedule(data: ScheduleRequest) { public postSchedule(data: ScheduleRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setData(data) .setData(data)
.build<ApiService>() .build;
.post<ScheduleResponse[]>();
return this.post<ScheduleResponse[]>(request);
} }
public getByGroup(id: number, isEven: boolean | null = null, disciplines: Array<number> | null = null, professors: Array<number> | null = null, lectureHalls: Array<number> | null = null) { public getByGroup(id: number, isEven: boolean | null = null, disciplines: Array<number> | null = null, professors: Array<number> | null = null, lectureHalls: Array<number> | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('GetByGroup/' + id.toString()) .setEndpoint('GetByGroup/' + id.toString())
.setQueryParams({isEven: isEven, disciplines: disciplines, professors: professors, lectureHalls: lectureHalls}) .setQueryParams({isEven: isEven, disciplines: disciplines, professors: professors, lectureHalls: lectureHalls})
.build<ApiService>() .build;
.get<ScheduleResponse[]>();
return this.get<ScheduleResponse[]>(request);
} }
public getByProfessor(id: number, isEven: boolean | null = null, disciplines: Array<number> | null = null, groups: Array<number> | null = null, lectureHalls: Array<number> | null = null) { public getByProfessor(id: number, isEven: boolean | null = null, disciplines: Array<number> | null = null, groups: Array<number> | null = null, lectureHalls: Array<number> | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('GetByProfessor/' + id.toString()) .setEndpoint('GetByProfessor/' + id.toString())
.setQueryParams({isEven: isEven, disciplines: disciplines, groups: groups, lectureHalls: lectureHalls}) .setQueryParams({isEven: isEven, disciplines: disciplines, groups: groups, lectureHalls: lectureHalls})
.build<ApiService>() .build;
.get<ScheduleResponse[]>();
return this.get<ScheduleResponse[]>(request);
} }
public getByLectureHall(id: number, isEven: boolean | null = null, disciplines: Array<number> | null = null, groups: Array<number> | null = null, professors: Array<number> | null = null) { public getByLectureHall(id: number, isEven: boolean | null = null, disciplines: Array<number> | null = null, groups: Array<number> | null = null, professors: Array<number> | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('GetByLectureHall/' + id.toString()) .setEndpoint('GetByLectureHall/' + id.toString())
.setQueryParams({isEven: isEven, disciplines: disciplines, groups: groups, professors: professors}) .setQueryParams({isEven: isEven, disciplines: disciplines, groups: groups, professors: professors})
.build<ApiService>() .build;
.get<ScheduleResponse[]>();
return this.get<ScheduleResponse[]>(request);
} }
public getByDiscipline(id: number, isEven: boolean | null = null, groups: Array<number> | null = null, professors: Array<number> | null = null, lectureHalls: Array<number> | null = null) { public getByDiscipline(id: number, isEven: boolean | null = null, groups: Array<number> | null = null, professors: Array<number> | null = null, lectureHalls: Array<number> | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('GetByDiscipline/' + id.toString()) .setEndpoint('GetByDiscipline/' + id.toString())
.setQueryParams({isEven: isEven, groups: groups, professors: professors, lectureHalls: lectureHalls}) .setQueryParams({isEven: isEven, groups: groups, professors: professors, lectureHalls: lectureHalls})
.build<ApiService>() .build;
.get<ScheduleResponse[]>();
return this.get<ScheduleResponse[]>(request);
} }
} }

View File

@ -14,101 +14,112 @@ export default class SetupService extends ApiService {
public readonly version = AvailableVersion.v1; public readonly version = AvailableVersion.v1;
public checkToken(token: string) { public checkToken(token: string) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('CheckToken') .setEndpoint('CheckToken')
.setQueryParams({token: token}) .setQueryParams({token: token})
.build<ApiService>() .build;
.get<boolean>();
return this.get<boolean>(request);
} }
public setPsql(data: DatabaseRequest) { public setPsql(data: DatabaseRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetPsql') .setEndpoint('SetPsql')
.setData(data) .setData(data)
.setWithCredentials() .setWithCredentials()
.build<ApiService>() .build;
.post<boolean>();
return this.post<boolean>(request);
} }
public setMysql(data: DatabaseRequest) { public setMysql(data: DatabaseRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetMysql') .setEndpoint('SetMysql')
.setData(data) .setData(data)
.setWithCredentials() .setWithCredentials()
.build<ApiService>() .build;
.post<boolean>();
return this.post<boolean>(request);
} }
public setSqlite(path: string | null = null) { public setSqlite(path: string | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetSqlite') .setEndpoint('SetSqlite')
.setQueryParams({path: path}) .setQueryParams({path: path})
.setWithCredentials() .setWithCredentials()
.build<ApiService>() .build;
.get<boolean>();
return this.get<boolean>(request);
} }
public setRedis(data: CacheRequest) { public setRedis(data: CacheRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetRedis') .setEndpoint('SetRedis')
.setData(data) .setData(data)
.setWithCredentials() .setWithCredentials()
.build<ApiService>() .build;
.post<boolean>();
return this.post<boolean>(request);
} }
public setMemcached() { public setMemcached() {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetMemcached') .setEndpoint('SetMemcached')
.setWithCredentials() .setWithCredentials()
.build<ApiService>() .build;
.post<boolean>();
return this.post<boolean>(request);
} }
public createAdmin(data: CreateUserRequest) { public createAdmin(data: CreateUserRequest) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('CreateAdmin') .setEndpoint('CreateAdmin')
.setData(data) .setData(data)
.setWithCredentials() .setWithCredentials()
.build<ApiService>() .build;
.post<boolean>();
return this.post<boolean>(request);
} }
public setLogging(data: LoggingRequest | null = null) { public setLogging(data: LoggingRequest | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetLogging') .setEndpoint('SetLogging')
.setData(data) .setData(data)
.setWithCredentials() .setWithCredentials()
.build<ApiService>() .build;
.post<boolean>();
return this.post<boolean>(request);
} }
public setEmail(data: EmailRequest | null = null) { public setEmail(data: EmailRequest | null = null) {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetEmail') .setEndpoint('SetEmail')
.setData(data) .setData(data)
.setWithCredentials() .setWithCredentials()
.build<ApiService>() .build;
.post<boolean>();
return this.post<boolean>(request);
} }
public setSchedule(data: ScheduleConfigurationRequest) { public setSchedule(data: ScheduleConfigurationRequest) {
data.startTerm = new DateOnly(data.startTerm).toString(); data.startTerm = new DateOnly(data.startTerm).toString();
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('SetSchedule') .setEndpoint('SetSchedule')
.setData(data) .setData(data)
.setWithCredentials() .setWithCredentials()
.build<ApiService>() .build;
.post<boolean>();
return this.post<boolean>(request);
} }
public submit() { public submit() {
return this.createRequestBuilder() let request = this.createRequestBuilder()
.setEndpoint('Submit') .setEndpoint('Submit')
.setWithCredentials() .setWithCredentials()
.build<ApiService>() .build;
.post<boolean>();
return this.post<boolean>(request);
} }
public isConfigured() { public isConfigured() {

View File

@ -4,7 +4,6 @@ import {FooterComponent} from "@component/common/footer/footer.component";
import localeRu from '@angular/common/locales/ru'; import localeRu from '@angular/common/locales/ru';
import {registerLocaleData} from '@angular/common'; import {registerLocaleData} from '@angular/common';
import {FocusNextDirective} from "@/directives/focus-next.directive"; import {FocusNextDirective} from "@/directives/focus-next.directive";
import {TokenRefreshService} from "@service/token-refresh.service";
import {HeaderComponent} from "@component/common/header/header.component"; import {HeaderComponent} from "@component/common/header/header.component";
@Component({ @Component({
@ -17,8 +16,7 @@ import {HeaderComponent} from "@component/common/header/header.component";
<app-footer/>` <app-footer/>`
}) })
export class AppComponent { export class AppComponent {
constructor(tokenRefreshService: TokenRefreshService) { constructor() {
registerLocaleData(localeRu); registerLocaleData(localeRu);
tokenRefreshService.startTokenRefresh();
} }
} }

View File

@ -1,24 +1,24 @@
<!--suppress CssInvalidPropertyValue --> <!--suppress CssInvalidPropertyValue -->
<button mat-button [matMenuTriggerFor]="menu" #menuTrigger="matMenuTrigger" [id]="idButton">{{ 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"> <mat-menu #menu="matMenu" [hasBackdrop]="false" class="menu-options">
<div (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()" style="padding: 0 15px 15px"> <div (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()" style="padding: 0 15px 15px">
<div class="header-menu"> <div class="header-menu">
<mat-form-field appearance="outline" color="accent" style="display:flex;"> <mat-form-field appearance="outline" color="accent" style="display:flex;">
<input matInput placeholder="Поиск..." [(ngModel)]="searchQuery" [disabled]="data.length === 0"> <input matInput placeholder="Поиск..." [(ngModel)]="searchQuery" [disabled]="data === null || data.length === 0">
<button mat-icon-button matSuffix (click)="clearSearchQuery()" [disabled]="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> <mat-icon style="color: var(--mdc-filled-button-label-text-color);">close</mat-icon>
</button> </button>
</mat-form-field> </mat-form-field>
<div class="button-group"> <div class="button-group">
<mat-checkbox (click)="checkData()" [disabled]="data.length === 0" #chooseCheckbox/> <mat-checkbox (click)="checkData()" [disabled]="data === null || data.length === 0" #chooseCheckbox/>
<button mat-button (click)="clearAll()" [disabled]="data.length === 0">Очистить</button> <button mat-button (click)="clearAll()" [disabled]="data === null || data.length === 0">Очистить</button>
</div> </div>
<hr/> <hr/>
</div> </div>
@if (data.length === 0) { @if (data === null || data.length === 0) {
<app-loading-indicator style="display: flex; justify-content: center;" [loading]="dataLoaded !== null" <app-loading-indicator style="display: flex; justify-content: center;" [loading]="data === null"
(retryFunction)="retryLoadData.emit()"/> (retryFunction)="retryLoadData.emit()"/>
} @else { } @else {
<mat-selection-list> <mat-selection-list>

View File

@ -47,17 +47,19 @@ export interface SelectData {
export class OtherComponent { export class OtherComponent {
private _searchQuery: string = ''; private _searchQuery: string = '';
protected filteredData: BehaviorSubject<SelectData[]> = new BehaviorSubject<SelectData[]>([]); protected filteredData: BehaviorSubject<SelectData[]> = new BehaviorSubject<SelectData[]>([]);
protected data: SelectData[] = []; protected data: SelectData[] | null = null;
@Input() idButton!: string; @Input() idButton!: string;
@Input() textButton!: string; @Input() textButton!: string;
@ViewChild('menuTrigger') menuTrigger!: MatMenuTrigger; @ViewChild('menuTrigger') menuTrigger!: MatMenuTrigger;
@ViewChild('chooseCheckbox') chooseCheckbox!: MatCheckbox; @ViewChild('chooseCheckbox') chooseCheckbox!: MatCheckbox;
@Input() dataLoaded: boolean | null = false;
@Output() retryLoadData: EventEmitter<void> = new EventEmitter<void>(); @Output() retryLoadData: EventEmitter<void> = new EventEmitter<void>();
get selectedIds(): number[] { get selectedIds(): number[] {
if (this.data === null)
return [];
return this.data.filter(x => x.selected).map(x => x.id); return this.data.filter(x => x.selected).map(x => x.id);
} }
@ -72,6 +74,9 @@ export class OtherComponent {
} }
private updateCheckBox() { private updateCheckBox() {
if (this.data === null)
return;
this.chooseCheckbox.checked = this.data.every(x => x.selected); this.chooseCheckbox.checked = this.data.every(x => x.selected);
this.chooseCheckbox.indeterminate = this.data.some(x => x.selected) && !this.chooseCheckbox.checked; this.chooseCheckbox.indeterminate = this.data.some(x => x.selected) && !this.chooseCheckbox.checked;
} }
@ -82,6 +87,9 @@ export class OtherComponent {
} }
protected updateFilteredData(): void { protected updateFilteredData(): void {
if (this.data === null)
return;
this.filteredData.next(this.data.filter(x => this.filteredData.next(this.data.filter(x =>
x.name.toLowerCase().includes(this.searchQuery.toLowerCase()) x.name.toLowerCase().includes(this.searchQuery.toLowerCase())
)); ));
@ -92,7 +100,7 @@ export class OtherComponent {
} }
protected clearAll(): void { protected clearAll(): void {
this.data.forEach(x => x.selected = false); this.data?.forEach(x => x.selected = false);
if (this.searchQuery !== '') { if (this.searchQuery !== '') {
const updatedData = this.filteredData.value.map(x => { const updatedData = this.filteredData.value.map(x => {
@ -109,7 +117,7 @@ export class OtherComponent {
const check: boolean = this.filteredData.value.some(x => !x.selected) && !this.filteredData.value.every(x => x.selected); const check: boolean = this.filteredData.value.some(x => !x.selected) && !this.filteredData.value.every(x => x.selected);
const updatedData = this.filteredData.value.map(data => { const updatedData = this.filteredData.value.map(data => {
this.data.find(x => x.id === data.id)!.selected = check; this.data!.find(x => x.id === data.id)!.selected = check;
return {...data, selected: check}; return {...data, selected: check};
}); });
@ -118,7 +126,7 @@ export class OtherComponent {
} }
protected checkboxStateChange(item: number) { protected checkboxStateChange(item: number) {
const data = this.data.find(x => x.id === item)!; const data = this.data!.find(x => x.id === item)!;
data.selected = !data.selected; data.selected = !data.selected;
const updatedData = this.filteredData.value; const updatedData = this.filteredData.value;
updatedData.find(x => x.id === item)!.selected = data.selected; updatedData.find(x => x.id === item)!.selected = data.selected;

View File

@ -20,7 +20,7 @@ export class HasRoleDirective {
this.authService this.authService
.getRole() .getRole()
.pipe(catchError(error => { .pipe(catchError(_ => {
this.viewContainer.clear(); this.viewContainer.clear();
return of(null); return of(null);
})) }))

View File

@ -40,11 +40,11 @@ export class LoginComponent {
protected loginButtonIsDisable: boolean = true; protected loginButtonIsDisable: boolean = true;
protected errorText: string = ''; protected errorText: string = '';
constructor(private formBuilder: FormBuilder, private auth: AuthApiService, private route: Router) { constructor(private formBuilder: FormBuilder, private auth: AuthApiService, private router: Router) {
this.auth.getRole() this.auth.getRole()
.subscribe(data => { .subscribe(data => {
if (data !== null) if (data !== null)
route.navigate(['admin']).then(); router.navigate(['admin']).then();
}); });
this.loginForm = this.formBuilder.group({ this.loginForm = this.formBuilder.group({
@ -89,7 +89,7 @@ export class LoginComponent {
.subscribe(_ => { .subscribe(_ => {
this.loaderActive = false; this.loaderActive = false;
this.errorText = ''; this.errorText = '';
this.route.navigate(['admin']).then(); this.router.navigate(['admin']).then();
}); });
} }
} }

View File

@ -2,7 +2,7 @@ import {Component} from '@angular/core';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
import {NavigationService} from "@service/navigation.service"; import {NavigationService} from "@service/navigation.service";
import SetupService from "@api/v1/setup.service"; import SetupService from "@api/v1/setup.service";
import {DateAdapter, MatNativeDateModule} from "@angular/material/core"; import {MAT_DATE_LOCALE, MatNativeDateModule} from "@angular/material/core";
import {MatFormFieldModule} from "@angular/material/form-field"; import {MatFormFieldModule} from "@angular/material/form-field";
import {MatSelectModule} from "@angular/material/select"; import {MatSelectModule} from "@angular/material/select";
import {MatInput} from "@angular/material/input"; import {MatInput} from "@angular/material/input";
@ -25,17 +25,16 @@ import {MatDatepickerModule} from "@angular/material/datepicker";
MatDatepickerModule, MatDatepickerModule,
MatNativeDateModule MatNativeDateModule
], ],
templateUrl: './schedule.component.html' templateUrl: './schedule.component.html',
providers: [{provide: MAT_DATE_LOCALE, useValue: 'ru-RU'}]
}) })
export class ScheduleComponent { export class ScheduleComponent {
protected scheduleSettings!: FormGroup; protected scheduleSettings!: FormGroup;
constructor( constructor(private navigationService: NavigationService, formBuilder: FormBuilder, private api: SetupService) {
private navigationService: NavigationService, private formBuilder: FormBuilder, private api: SetupService, private _adapter: DateAdapter<any>) { this.scheduleSettings = formBuilder.group({
this._adapter.setLocale('ru'); cron: ['0 */6 * * *', Validators.pattern(/^(\S+\s){4}\S$/)],
this.scheduleSettings = this.formBuilder.group({
cron: ['0 */6 * * *', Validators.pattern(/^([^\s]+\s){4}[^\s]{1}$/)],
startTerm: ['', Validators.required] startTerm: ['', Validators.required]
} }
); );

View File

@ -1,6 +1,6 @@
import {EventEmitter, Injectable} from '@angular/core'; import {Injectable} from '@angular/core';
import {HttpClient, HttpHeaders} from "@angular/common/http"; import {HttpClient, HttpHeaders} from "@angular/common/http";
import {Observable, tap} from "rxjs"; import {map, Observable, throwError} from "rxjs";
import {TokenResponse} from "@api/v1/tokenResponse"; import {TokenResponse} from "@api/v1/tokenResponse";
import ApiService from "@api/api.service"; import ApiService from "@api/api.service";
@ -21,7 +21,7 @@ export class AuthToken {
this.endpoint = refreshEndpoint; this.endpoint = refreshEndpoint;
} }
static httpHeader(token: AuthToken): HttpHeaders { public static httpHeader(token: AuthToken): HttpHeaders {
let header = new HttpHeaders(); let header = new HttpHeaders();
if (token.authProvider === AvailableAuthenticationProvider.Bearer) if (token.authProvider === AvailableAuthenticationProvider.Bearer)
@ -35,8 +35,6 @@ export class AuthToken {
providedIn: 'root', providedIn: 'root',
}) })
export class AuthService { export class AuthService {
public expireTokenChange = new EventEmitter<Date>();
constructor(private http: HttpClient) { constructor(private http: HttpClient) {
} }
@ -56,12 +54,11 @@ export class AuthService {
return result <= new Date() ? new Date() : result; return result <= new Date() ? new Date() : result;
} }
public refreshToken(): Observable<TokenResponse> { public refreshToken(): Observable<Date> {
const token = localStorage.getItem(ApiService.tokenKey); const token = localStorage.getItem(ApiService.tokenKey);
console.log(token);
if (!token) if (!token)
throw new Error("token is not found"); return throwError(() => new Error("Token is not found"));
const authToken = JSON.parse(token) as AuthToken; const authToken = JSON.parse(token) as AuthToken;
@ -69,14 +66,16 @@ export class AuthService {
case AvailableAuthenticationProvider.Bearer: case AvailableAuthenticationProvider.Bearer:
return this.http.get<TokenResponse>(authToken.endpoint, {withCredentials: true}) return this.http.get<TokenResponse>(authToken.endpoint, {withCredentials: true})
.pipe( .pipe(
tap(response => { map(response => {
const newExpireDate = new Date(response.expiresIn); const newExpireDate = new Date(response.expiresIn);
const oldExpireDate = new Date(authToken.expiresIn); const oldExpireDate = new Date(authToken.expiresIn);
if (newExpireDate.getTime() !== oldExpireDate.getTime()) { if (newExpireDate.getTime() !== oldExpireDate.getTime()) {
AuthService.setToken(response, AvailableAuthenticationProvider.Bearer, authToken.endpoint); AuthService.setToken(response, AvailableAuthenticationProvider.Bearer, authToken.endpoint);
this.expireTokenChange.emit(newExpireDate); return newExpireDate;
} }
return newExpireDate;
}) })
); );
} }

View File

@ -1,4 +1,4 @@
import {BehaviorSubject, filter, interval, Subscription, switchMap} from "rxjs"; import {BehaviorSubject, catchError, of, Subject} from "rxjs";
import {Injectable} from "@angular/core"; import {Injectable} from "@angular/core";
import {AuthService} from "@service/auth.service"; import {AuthService} from "@service/auth.service";
import {environment} from "@environment"; import {environment} from "@environment";
@ -8,64 +8,75 @@ import ApiService from "@api/api.service";
providedIn: 'root', providedIn: 'root',
}) })
export class TokenRefreshService { export class TokenRefreshService {
private tokenRefreshSubscription: Subscription | undefined;
private tokenRefreshing$ = new BehaviorSubject<boolean>(false); private tokenRefreshing$ = new BehaviorSubject<boolean>(false);
private refreshTokenTimeout: any;
private refreshTokenExpireMs: number = environment.retryDelay; private refreshTokenExpireMs: number = environment.retryDelay;
constructor(private authService: AuthService) { constructor(private authService: AuthService) {
this.setRefreshTokenExpireMs(AuthService.tokenExpiresIn.getTime() - 1000 - Date.now()); this.setRefreshTokenExpireMs(AuthService.tokenExpiresIn);
authService.expireTokenChange.subscribe(date => {
console.debug('Expire token change event received:', date);
this.setRefreshTokenExpireMs(date.getTime() - 1000 - Date.now());
});
} }
public startTokenRefresh(date: Date | null = null): void { private startTokenRefresh(): void {
if (date) this.refreshTokenTimeout = setTimeout(() => {
this.refreshTokenExpireMs = new Date(date).getTime() - 1000 - Date.now(); this.refreshToken();
}, this.refreshTokenExpireMs);
}
console.debug(this.tokenRefreshSubscription); private refreshToken(): void {
if (this.tokenRefreshSubscription && !this.tokenRefreshSubscription.closed) if (this.tokenRefreshing$.value)
return; return;
this.tokenRefreshSubscription = interval(this.refreshTokenExpireMs).pipe( this.tokenRefreshing$.next(true);
filter(isRefreshing => !isRefreshing),
switchMap(() => { this.authService.refreshToken()
this.tokenRefreshing$.next(true); .pipe(
console.debug('Send query to refresh token'); catchError(error => {
return this.authService.refreshToken(); if (error.status === 403 || error.status === 401 || !localStorage.getItem(ApiService.tokenKey)) {
}) localStorage.removeItem(ApiService.tokenKey);
).subscribe({ return of(undefined);
next: (_) => { }
let retryTime = this.refreshTokenExpireMs;
if (retryTime < environment.retryDelay)
retryTime = environment.retryDelay;
// 15 minutes
if (retryTime * 2 <= 900_000)
retryTime *= 2;
else
retryTime = 900_000;
return of(retryTime);
}))
.subscribe(data => {
if (data)
this.setRefreshTokenExpireMs(data);
this.tokenRefreshing$.next(false); this.tokenRefreshing$.next(false);
}, });
error: error => {
this.tokenRefreshing$.next(false);
localStorage.removeItem(ApiService.tokenKey);
}
});
} }
public getTokenRefreshing$(): BehaviorSubject<boolean> { public getTokenRefreshing$(): Subject<boolean> {
return this.tokenRefreshing$; return this.tokenRefreshing$;
} }
public stopTokenRefresh(): void { public setRefreshTokenExpireMs(expireMs: number | string | Date | null = null): void {
if (this.tokenRefreshSubscription && !this.tokenRefreshSubscription.closed) { let expireMsNumber: number;
this.tokenRefreshSubscription.unsubscribe(); if (expireMs === null)
this.tokenRefreshSubscription = undefined; expireMsNumber = -1;
} else if (expireMs instanceof Date || typeof expireMs === 'string')
} expireMsNumber = new Date(expireMs).getTime() - 1000 - Date.now();
else
expireMsNumber = expireMs;
public setRefreshTokenExpireMs(expireMs: number): void { if (expireMsNumber < environment.retryDelay)
if (expireMs < environment.retryDelay) expireMsNumber = environment.retryDelay;
expireMs = environment.retryDelay;
this.refreshTokenExpireMs = expireMs; this.refreshTokenExpireMs = expireMsNumber;
console.log('New refresh token interval:', this.refreshTokenExpireMs);
console.log(expireMs); clearTimeout(this.refreshTokenTimeout);
this.stopTokenRefresh();
this.startTokenRefresh(); this.startTokenRefresh();
} }
} }