Compare commits

..

5 Commits

Author SHA1 Message Date
95a593bdb6 feat: add auth api
All checks were successful
Build and Deploy Angular App / build (push) Successful in 55s
2024-08-04 23:15:38 +03:00
f4b25f428d feat: add login page 2024-08-04 23:14:45 +03:00
b764f2a77b build: update ref 2024-08-04 23:13:56 +03:00
95165a0940 feat: create has-role directive for simple ACL 2024-08-04 23:13:43 +03:00
e9735a4e99 fix: 2024-08-04 23:03:06 +03:00
17 changed files with 581 additions and 1031 deletions

1202
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,29 +10,29 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^18.1.1", "@angular/animations": "^18.1.3",
"@angular/cdk": "~18.1.1", "@angular/cdk": "~18.1.3",
"@angular/cdk-experimental": "^18.1.1", "@angular/cdk-experimental": "^18.1.3",
"@angular/common": "^18.1.1", "@angular/common": "^18.1.3",
"@angular/compiler": "^18.1.1", "@angular/compiler": "^18.1.3",
"@angular/core": "^18.1.1", "@angular/core": "^18.1.3",
"@angular/forms": "^18.1.1", "@angular/forms": "^18.1.3",
"@angular/material": "~18.1.1", "@angular/material": "~18.1.3",
"@angular/platform-browser": "^18.1.1", "@angular/platform-browser": "^18.1.3",
"@angular/platform-browser-dynamic": "^18.1.1", "@angular/platform-browser-dynamic": "^18.1.3",
"@angular/router": "^18.1.1", "@angular/router": "^18.1.3",
"@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.6.3",
"zone.js": "~0.14.8" "zone.js": "~0.14.8"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^18.1.1", "@angular-devkit/build-angular": "^18.1.3",
"@angular/cli": "^18.1.1", "@angular/cli": "^18.1.3",
"@angular/compiler-cli": "^18.1.1", "@angular/compiler-cli": "^18.1.3",
"@types/jasmine": "~5.1.4", "@types/jasmine": "~5.1.4",
"jasmine-core": "~5.2.0", "jasmine-core": "~5.2.0",
"karma": "~6.4.3", "karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1", "karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",

View File

@ -98,7 +98,7 @@ export class RequestBuilder {
data: null, data: null,
silenceMode: false, silenceMode: false,
withCredentials: false withCredentials: false
} };
} }
public reset(): void { public reset(): void {

View File

@ -73,17 +73,16 @@ export default abstract class ApiService implements SetRequestBuilderAfterBuild
} }
protected get combinedUrl() { protected get combinedUrl() {
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, this.request.endpoint), this.request.queryParams);
} }
private makeHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put'): Observable<Type> { private makeHttpRequest<Type>(method: 'get' | 'post' | 'delete' | 'put'): Observable<Type> {
const doneEndpoint = this.combinedUrl; const doneEndpoint = this.combinedUrl;
return this.tokenRefreshService.getTokenRefreshing$().pipe( return this.tokenRefreshService.getTokenRefreshing$().pipe(
filter(refreshing => !refreshing), filter(isRefreshing => !isRefreshing),
take(1), switchMap(() =>
switchMap(_ => { this.http.request<Type>(method, doneEndpoint, {
return this.http.request<Type>(method, doneEndpoint, {
withCredentials: this.request.withCredentials, withCredentials: this.request.withCredentials,
headers: this.request.httpHeaders, headers: this.request.httpHeaders,
body: this.request.data body: this.request.data
@ -97,8 +96,8 @@ export default abstract class ApiService implements SetRequestBuilderAfterBuild
this.request = RequestBuilder.getStandardRequestData(); this.request = RequestBuilder.getStandardRequestData();
throw error; throw error;
}) })
); )
}) )
); );
} }
@ -138,7 +137,7 @@ export default abstract class ApiService implements SetRequestBuilderAfterBuild
return this; return this;
const authToken = AuthToken.httpHeader((JSON.parse(token) as AuthToken)); const authToken = AuthToken.httpHeader((JSON.parse(token) as AuthToken));
authToken.keys().forEach(key => this.request.httpHeaders = this.request.httpHeaders.append(key, authToken.get(key) ?? '')) authToken.keys().forEach(key => this.request.httpHeaders = this.request.httpHeaders.append(key, authToken.get(key) ?? ''));
return this; return this;
} }

View File

@ -0,0 +1,54 @@
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) {
return this.createRequestBuilder()
.setEndpoint('Login')
.setData(login)
.build<ApiService>()
.post<TokenResponse>()
.pipe(
tap(response => {
AuthService.setToken(response, AvailableAuthenticationProvider.Bearer, this.createRequestBuilder().setEndpoint('ReLogin').build<AuthApiService>().combinedUrl);
this.tokenRefreshService.startTokenRefresh(response.expiresIn);
})
);
}
public logout() {
return this.createRequestBuilder()
.setWithCredentials()
.setEndpoint('Logout')
.build<ApiService>()
.addAuth()
.get()
.pipe(
tap(_ => {
localStorage.removeItem(ApiService.tokenKey);
})
);
}
public getRole(isSilence: boolean = true) {
return this.createRequestBuilder()
.setSilenceMode(isSilence)
.build<ApiService>()
.addAuth()
.get<AuthRoles>('GetRole')
.pipe(
catchError(_ => {
return of(null);
})
);
}
}

View File

@ -26,7 +26,7 @@ export class ScheduleService extends ApiService {
.post<ScheduleResponse[]>(); .post<ScheduleResponse[]>();
} }
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() return 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})
@ -34,7 +34,7 @@ export class ScheduleService extends ApiService {
.get<ScheduleResponse[]>(); .get<ScheduleResponse[]>();
} }
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() return 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})
@ -42,7 +42,7 @@ export class ScheduleService extends ApiService {
.get<ScheduleResponse[]>(); .get<ScheduleResponse[]>();
} }
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() return 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})
@ -50,7 +50,7 @@ export class ScheduleService extends ApiService {
.get<ScheduleResponse[]>(); .get<ScheduleResponse[]>();
} }
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() return 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})

View File

@ -2,7 +2,7 @@ import {Component} from '@angular/core';
import {RouterOutlet} from '@angular/router'; import {RouterOutlet} from '@angular/router';
import {FooterComponent} from "@component/common/footer/footer.component"; 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 {TokenRefreshService} from "@service/token-refresh.service";

View File

@ -1,8 +1,8 @@
import { ApplicationConfig } from '@angular/core'; import {ApplicationConfig} from '@angular/core';
import { provideRouter } from '@angular/router'; import {provideRouter} from '@angular/router';
import { routes } from './app.routes'; import {routes} from './app.routes';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
import {provideHttpClient} from "@angular/common/http"; import {provideHttpClient} from "@angular/common/http";
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {

View File

@ -8,6 +8,7 @@ import {ScheduleComponent as SetupScheduleComponent} from "@page/setup/schedule/
import {SetupComponent} from "@page/setup/setup.component"; import {SetupComponent} from "@page/setup/setup.component";
import {CreateAdminComponent} from "@page/setup/create-admin/create-admin.component"; import {CreateAdminComponent} from "@page/setup/create-admin/create-admin.component";
import {SummaryComponent} from "@page/setup/summary/summary.component"; import {SummaryComponent} from "@page/setup/summary/summary.component";
import {LoginComponent} from "@page/login/login.component";
export const routes: Routes = [ export const routes: Routes = [
{path: '', title: 'Расписание', pathMatch: 'full', component: ScheduleComponent}, {path: '', title: 'Расписание', pathMatch: 'full', component: ScheduleComponent},
@ -22,7 +23,8 @@ export const routes: Routes = [
{path: 'summary', component: SummaryComponent}, {path: 'summary', component: SummaryComponent},
{path: '', redirectTo: 'welcome', pathMatch: 'full'} {path: '', redirectTo: 'welcome', pathMatch: 'full'}
] ]
} },
{path: 'login', title: 'Вход', component: LoginComponent},
/*{path: 'not-found', title: '404 страница не найдена'}, /*{path: 'not-found', title: '404 страница не найдена'},
{path: '**', redirectTo: '/not-found'}*/ {path: '**', redirectTo: '/not-found'}*/
]; ];

View File

@ -0,0 +1,34 @@
import {Directive, Input, TemplateRef, ViewContainerRef} from '@angular/core';
import AuthApiService from "@api/v1/authApiService";
import {AuthRoles} from "@model/AuthRoles";
import {catchError, of} from "rxjs";
@Directive({
selector: '[appHasRole]',
standalone: true,
providers: [AuthApiService]
})
export class HasRoleDirective {
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef,
private authService: AuthApiService
) {}
@Input() set appHasRole(role: AuthRoles) {
this.viewContainer.clear();
this.authService
.getRole()
.pipe(catchError(error => {
this.viewContainer.clear();
return of(null);
}))
.subscribe(data => {
if (data === role)
this.viewContainer.createEmbeddedView(this.templateRef);
else
this.viewContainer.clear();
})
}
}

View File

@ -0,0 +1,26 @@
.formLogin {
display: flex;
padding: 20px;
justify-content: center;
align-items: center;
min-height: 40vh;
height: auto;
}
.formLogin mat-card {
padding: 25px;
}
.formLogin p {
text-align: center
}
.formLogin form {
display: flex;
flex-direction:column;
}
.formLoginButton {
display: flex;
justify-content: flex-end;
}

View File

@ -0,0 +1,76 @@
<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 (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>
<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>
</form>
<mat-error>
{{errorText}}
</mat-error>
<div class="formLoginButton">
@if (loaderActive) {
<app-data-spinner [scale]="40"/>
} @else {
<button mat-flat-button color="accent"
[disabled]="loginButtonIsDisable"
(click)="login()"
id="loginNextFocus">
Войти
</button>
}
</div>
</mat-card>
</mat-sidenav-container>

View File

@ -0,0 +1,95 @@
import {Component} from '@angular/core';
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 {MatCard} from "@angular/material/card";
import {FormBuilder, 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 {Router} from "@angular/router";
import {catchError, of} from "rxjs";
@Component({
selector: 'app-login',
standalone: true,
imports: [
MatSidenavContainer,
MatFormFieldModule,
MatInput,
MatTooltip,
MatIcon,
MatIconButton,
MatButton,
MatCard,
ReactiveFormsModule,
FocusNextDirective,
DataSpinnerComponent
],
templateUrl: './login.component.html',
styleUrl: './login.component.css',
providers: [AuthApiService]
})
export class LoginComponent {
protected loginForm!: FormGroup;
protected hidePass: boolean = true;
protected loaderActive: boolean = false;
protected loginButtonIsDisable: boolean = true;
protected errorText: string = '';
constructor(private formBuilder: FormBuilder, private auth: AuthApiService, private route: Router) {
this.auth.getRole()
.subscribe(data => {
if (data != null)
route.navigate(['admin']).then();
});
this.loginForm = this.formBuilder.group({
user: ['',],
password: ['',]
}
);
this.loginForm.get('password')?.setValidators([
Validators.required,
Validators.minLength(8)
]);
this.loginForm.get('user')?.setValidators([
Validators.required,
Validators.minLength(4)
]);
this.loginForm.valueChanges.subscribe(() => {
this.loginButtonIsDisable = !this.loginForm.valid;
});
}
protected togglePassword(event: MouseEvent) {
this.hidePass = !this.hidePass;
event.stopPropagation();
}
protected login() {
this.loaderActive = true;
this.auth.login({
username: this.loginForm.get('user')?.value,
password: this.loginForm.get('password')?.value
})
.pipe(catchError(error => {
this.loaderActive = false;
this.errorText = error.error;
this.loginButtonIsDisable = true;
throw error;
}))
.subscribe(_ => {
this.loaderActive = false;
this.errorText = '';
this.route.navigate(['admin']).then();
});
}
}

View File

@ -1,5 +1,6 @@
<mat-sidenav-container class="schedule"> <mat-sidenav-container class="schedule">
<app-tabs (eventResult)="result($event)"/> <app-tabs (eventResult)="result($event)"/>
<app-table-header [startWeek]="startWeek" [currentWeek]="currentWeek" (weekEvent)="handleWeekEvent($event)" #tableHeader/> <app-table-header [startWeek]="startWeek" [currentWeek]="currentWeek" (weekEvent)="handleWeekEvent($event)"
#tableHeader/>
<app-table [currentWeek]="currentWeek" [startWeek]="startWeek" [data]="data" [isLoad]="isLoadTable"/> <app-table [currentWeek]="currentWeek" [startWeek]="startWeek" [data]="data" [isLoad]="isLoadTable"/>
</mat-sidenav-container> </mat-sidenav-container>

View File

@ -61,7 +61,7 @@ export class AuthService {
const token = localStorage.getItem(ApiService.tokenKey); const token = localStorage.getItem(ApiService.tokenKey);
if (!token) if (!token)
return of(); return of({} as TokenResponse);
const authToken = JSON.parse(token) as AuthToken; const authToken = JSON.parse(token) as AuthToken;

View File

@ -1,7 +1,8 @@
import {BehaviorSubject, interval, Subscription, switchMap} from "rxjs"; import {BehaviorSubject, filter, interval, Subscription, switchMap} 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";
import ApiService from "@api/api.service";
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -15,12 +16,12 @@ export class TokenRefreshService {
this.setRefreshTokenExpireMs(AuthService.tokenExpiresIn.getTime() - 1000 - Date.now()); this.setRefreshTokenExpireMs(AuthService.tokenExpiresIn.getTime() - 1000 - Date.now());
authService.tokenChangeError.subscribe(_ => { authService.tokenChangeError.subscribe(_ => {
console.log('Token change error event received'); console.debug('Token change error event received');
this.tokenRefreshing$.next(false); this.tokenRefreshing$.next(false);
this.stopTokenRefresh(); this.stopTokenRefresh();
}); });
authService.expireTokenChange.subscribe(date => { authService.expireTokenChange.subscribe(date => {
console.log('Expire token change event received:', date); console.debug('Expire token change event received:', date);
this.setRefreshTokenExpireMs(date.getTime() - 1000 - Date.now()); this.setRefreshTokenExpireMs(date.getTime() - 1000 - Date.now());
}); });
} }
@ -29,22 +30,26 @@ export class TokenRefreshService {
if (date) if (date)
this.refreshTokenExpireMs = new Date(date).getTime() - 1000 - Date.now(); this.refreshTokenExpireMs = new Date(date).getTime() - 1000 - Date.now();
if (!this.tokenRefreshSubscription || this.tokenRefreshSubscription.closed) { console.debug(this.tokenRefreshSubscription);
this.tokenRefreshSubscription = interval(this.refreshTokenExpireMs).pipe( if (this.tokenRefreshSubscription && !this.tokenRefreshSubscription.closed)
switchMap(() => { return;
this.tokenRefreshing$.next(true);
return this.authService.refreshToken(); this.tokenRefreshSubscription = interval(this.refreshTokenExpireMs).pipe(
}) filter(isRefreshing => !isRefreshing),
).subscribe({ switchMap(() => {
next: (_) => { this.tokenRefreshing$.next(true);
this.tokenRefreshing$.next(false); console.debug('Send query to refresh token');
}, return this.authService.refreshToken();
error: error => { })
console.error('Token refresh error:', error); ).subscribe({
this.tokenRefreshing$.next(false); next: (_) => {
} this.tokenRefreshing$.next(false);
}); },
} error: error => {
this.tokenRefreshing$.next(false);
localStorage.removeItem(ApiService.tokenKey);
}
});
} }
public getTokenRefreshing$(): BehaviorSubject<boolean> { public getTokenRefreshing$(): BehaviorSubject<boolean> {
@ -60,13 +65,12 @@ export class TokenRefreshService {
public setRefreshTokenExpireMs(expireMs: number): void { public setRefreshTokenExpireMs(expireMs: number): void {
if (expireMs < environment.retryDelay) if (expireMs < environment.retryDelay)
expireMs = 3000; expireMs = environment.retryDelay;
console.log(expireMs);
this.refreshTokenExpireMs = expireMs; this.refreshTokenExpireMs = expireMs;
console.log(expireMs);
this.stopTokenRefresh(); this.stopTokenRefresh();
this.startTokenRefresh(); this.startTokenRefresh();
} }
} }

View File

@ -0,0 +1,3 @@
export enum AuthRoles {
Admin
}