Installing ng-matero
This commit is contained in:
@@ -1,10 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
|
||||
const routes: Routes = [];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -1,3 +0,0 @@
|
||||
<app-auth></app-auth>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
@@ -1,35 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
||||
],
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have as title 'app'`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('app');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.content span')?.textContent).toContain('app app is running!');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, AfterViewInit } from '@angular/core';
|
||||
import { PreloaderService } from '@core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.css']
|
||||
template: '<router-outlet></router-outlet>',
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'app';
|
||||
export class AppComponent implements OnInit, AfterViewInit {
|
||||
constructor(private preloader: PreloaderService) {}
|
||||
|
||||
ngOnInit() {}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.preloader.hide();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { HttpClientModule, HttpClient } from '@angular/common/http';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { AuthComponent } from './auth/auth.component';
|
||||
|
||||
import { CoreModule } from '@core/core.module';
|
||||
import { ThemeModule } from '@theme/theme.module';
|
||||
import { SharedModule } from '@shared/shared.module';
|
||||
import { RoutesModule } from './routes/routes.module';
|
||||
import { FormlyConfigModule } from './formly-config.module';
|
||||
import { NgxPermissionsModule } from 'ngx-permissions';
|
||||
import { ToastrModule } from 'ngx-toastr';
|
||||
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
|
||||
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
|
||||
|
||||
import { environment } from '@env/environment';
|
||||
import { BASE_URL, httpInterceptorProviders, appInitializerProviders } from '@core';
|
||||
|
||||
// Required for AOT compilation
|
||||
export function TranslateHttpLoaderFactory(http: HttpClient) {
|
||||
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
|
||||
}
|
||||
|
||||
import { LoginService } from '@core/authentication/login.service';
|
||||
import { FakeLoginService } from './fake-login.service';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
AuthComponent
|
||||
|
||||
],
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
AppRoutingModule,
|
||||
BrowserAnimationsModule
|
||||
HttpClientModule,
|
||||
CoreModule,
|
||||
ThemeModule,
|
||||
RoutesModule,
|
||||
SharedModule,
|
||||
FormlyConfigModule.forRoot(),
|
||||
NgxPermissionsModule.forRoot(),
|
||||
ToastrModule.forRoot(),
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useFactory: TranslateHttpLoaderFactory,
|
||||
deps: [HttpClient],
|
||||
},
|
||||
}),
|
||||
BrowserAnimationsModule,
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
providers: [
|
||||
{ provide: BASE_URL, useValue: environment.baseUrl },
|
||||
{ provide: LoginService, useClass: FakeLoginService }, // <= Remove it in the real APP
|
||||
httpInterceptorProviders,
|
||||
appInitializerProviders,
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule { }
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<p>auth works!</p>
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthComponent } from './auth.component';
|
||||
|
||||
describe('AuthComponent', () => {
|
||||
let component: AuthComponent;
|
||||
let fixture: ComponentFixture<AuthComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ AuthComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AuthComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth',
|
||||
templateUrl: './auth.component.html',
|
||||
styleUrls: ['./auth.component.css']
|
||||
})
|
||||
export class AuthComponent {
|
||||
|
||||
}
|
||||
17
front/app/src/app/core/authentication/README.md
Normal file
17
front/app/src/app/core/authentication/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Authentication
|
||||
|
||||
1. Modify the token key at `token.service` to another name such as `TOKEN` or `your-app-token`. By default set to `ng-matero-token`.
|
||||
|
||||
2. Replace the APIs at `login.service` with your owns.
|
||||
|
||||
- `/auth/login` Login
|
||||
- `/auth/refresh` Refresh
|
||||
- `/auth/logout` Logout
|
||||
- `/me` Get user information
|
||||
- `/me/menu` Get user menu
|
||||
|
||||
3. If you have modified the login url (defaults to `auth/login`), you should correct it in the following files.
|
||||
|
||||
- `auth.guard.ts`
|
||||
- `error-interceptor.ts`
|
||||
- `token-interceptor.ts`
|
||||
56
front/app/src/app/core/authentication/auth.guard.spec.ts
Normal file
56
front/app/src/app/core/authentication/auth.guard.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||
import { TokenService, AuthGuard, AuthService } from '@core/authentication';
|
||||
|
||||
@Component({ template: '' })
|
||||
class DummyComponent {}
|
||||
|
||||
describe('AuthGuard', () => {
|
||||
const route: any = {};
|
||||
const state: any = {};
|
||||
let router: Router;
|
||||
let authGuard: AuthGuard;
|
||||
let authService: AuthService;
|
||||
let tokenService: TokenService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
RouterTestingModule.withRoutes([
|
||||
{ path: 'dashboard', component: DummyComponent, canActivate: [AuthGuard] },
|
||||
{ path: 'auth/login', component: DummyComponent },
|
||||
]),
|
||||
],
|
||||
declarations: [DummyComponent],
|
||||
providers: [{ provide: LocalStorageService, useClass: MemoryStorageService }],
|
||||
});
|
||||
TestBed.createComponent(DummyComponent);
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
authGuard = TestBed.inject(AuthGuard);
|
||||
authService = TestBed.inject(AuthService);
|
||||
tokenService = TestBed.inject(TokenService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(authGuard).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should be authenticated', () => {
|
||||
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||
|
||||
expect(authGuard.canActivate(route, state)).toBeTrue();
|
||||
});
|
||||
|
||||
it('should redirect to /auth/login when authenticate failed', () => {
|
||||
spyOn(authService, 'check').and.returnValue(false);
|
||||
|
||||
expect(authGuard.canActivate(route, state)).toEqual(router.parseUrl('/auth/login'));
|
||||
});
|
||||
});
|
||||
32
front/app/src/app/core/authentication/auth.guard.ts
Normal file
32
front/app/src/app/core/authentication/auth.guard.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
CanActivate,
|
||||
CanActivateChild,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
UrlTree,
|
||||
} from '@angular/router';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthGuard implements CanActivate, CanActivateChild {
|
||||
constructor(private auth: AuthService, private router: Router) {}
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
|
||||
return this.authenticate();
|
||||
}
|
||||
|
||||
canActivateChild(
|
||||
childRoute: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): boolean | UrlTree {
|
||||
return this.authenticate();
|
||||
}
|
||||
|
||||
private authenticate(): boolean | UrlTree {
|
||||
return this.auth.check() ? true : this.router.parseUrl('/auth/login');
|
||||
}
|
||||
}
|
||||
133
front/app/src/app/core/authentication/auth.service.spec.ts
Normal file
133
front/app/src/app/core/authentication/auth.service.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { Observable } from 'rxjs';
|
||||
import { skip } from 'rxjs/operators';
|
||||
import { HttpRequest } from '@angular/common/http';
|
||||
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||
import { AuthService, LoginService, TokenService, User } from '@core/authentication';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: AuthService;
|
||||
let loginService: LoginService;
|
||||
let tokenService: TokenService;
|
||||
let httpMock: HttpTestingController;
|
||||
let user$: Observable<User>;
|
||||
const email = 'foo@bar.com';
|
||||
const token = { access_token: 'token', token_type: 'bearer' };
|
||||
const user = { id: 1, email };
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [{ provide: LocalStorageService, useClass: MemoryStorageService }],
|
||||
});
|
||||
loginService = TestBed.inject(LoginService);
|
||||
authService = TestBed.inject(AuthService);
|
||||
tokenService = TestBed.inject(TokenService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
user$ = authService.user();
|
||||
authService.change().subscribe(user => {
|
||||
expect(user).toBeInstanceOf(Object);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should be created', () => {
|
||||
expect(authService).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should log in failed', () => {
|
||||
authService.login(email, 'password', false).subscribe(isLogin => expect(isLogin).toBeFalse());
|
||||
httpMock.expectOne('/auth/login').flush({});
|
||||
|
||||
expect(authService.check()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should log in successful and get user info', () => {
|
||||
user$.pipe(skip(1)).subscribe(currentUser => expect(currentUser.id).toEqual(user.id));
|
||||
authService.login(email, 'password', false).subscribe(isLogin => expect(isLogin).toBeTrue());
|
||||
httpMock.expectOne('/auth/login').flush(token);
|
||||
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectOne('/me').flush(user);
|
||||
});
|
||||
|
||||
it('should log out failed when user is not login', () => {
|
||||
spyOn(loginService, 'logout').and.callThrough();
|
||||
expect(authService.check()).toBeFalse();
|
||||
|
||||
authService.logout().subscribe();
|
||||
httpMock.expectOne('/auth/logout');
|
||||
|
||||
expect(authService.check()).toBeFalse();
|
||||
expect(loginService.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log out successful when user is login', () => {
|
||||
tokenService.set(token);
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectOne('/me').flush(user);
|
||||
|
||||
user$.pipe(skip(1)).subscribe(currentUser => expect(currentUser.id).toBeUndefined());
|
||||
authService.logout().subscribe();
|
||||
httpMock.expectOne('/auth/logout').flush({});
|
||||
|
||||
expect(authService.check()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should refresh token when access_token is valid', fakeAsync(() => {
|
||||
tokenService.set(Object.assign({ expires_in: 5 }, token));
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectOne('/me').flush(user);
|
||||
const match = (req: HttpRequest<any>) => req.url === '/auth/refresh' && !req.body.refresh_token;
|
||||
|
||||
tick(4000);
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.match(match)[0].flush(token);
|
||||
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectNone('/me');
|
||||
tokenService.ngOnDestroy();
|
||||
}));
|
||||
|
||||
it('should refresh token when access_token is invalid and refresh_token is valid', fakeAsync(() => {
|
||||
tokenService.set(Object.assign({ expires_in: 5, refresh_token: 'foo' }, token));
|
||||
const match = (req: HttpRequest<any>) =>
|
||||
req.url === '/auth/refresh' && req.body.refresh_token === 'foo';
|
||||
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectOne('/me').flush(user);
|
||||
tick(10000);
|
||||
expect(authService.check()).toBeFalse();
|
||||
httpMock.match(match)[0].flush(token);
|
||||
|
||||
expect(authService.check()).toBeTrue();
|
||||
httpMock.expectNone('/me');
|
||||
tokenService.ngOnDestroy();
|
||||
}));
|
||||
|
||||
it('it should clear token when access_token is invalid and refresh token response is 401', fakeAsync(() => {
|
||||
spyOn(tokenService, 'set').and.callThrough();
|
||||
tokenService.set(Object.assign({ expires_in: 5, refresh_token: 'foo' }, token));
|
||||
const match = (req: HttpRequest<any>) =>
|
||||
req.url === '/auth/refresh' && req.body.refresh_token === 'foo';
|
||||
|
||||
tick(10000);
|
||||
expect(authService.check()).toBeFalse();
|
||||
httpMock.expectOne('/me').flush({});
|
||||
httpMock.match(match)[0].flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(authService.check()).toBeFalse();
|
||||
expect(tokenService.set).toHaveBeenCalledWith(undefined);
|
||||
tokenService.ngOnDestroy();
|
||||
}));
|
||||
|
||||
it('it only call http request once when on change subscribe twice', () => {
|
||||
authService.change().subscribe();
|
||||
tokenService.set(token);
|
||||
httpMock.expectOne('/me').flush({});
|
||||
});
|
||||
});
|
||||
79
front/app/src/app/core/authentication/auth.service.ts
Normal file
79
front/app/src/app/core/authentication/auth.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, iif, merge, of } from 'rxjs';
|
||||
import { catchError, map, share, switchMap, tap } from 'rxjs/operators';
|
||||
import { TokenService } from './token.service';
|
||||
import { LoginService } from './login.service';
|
||||
import { filterObject, isEmptyObject } from './helpers';
|
||||
import { User } from './interface';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
private user$ = new BehaviorSubject<User>({});
|
||||
private change$ = merge(
|
||||
this.tokenService.change(),
|
||||
this.tokenService.refresh().pipe(switchMap(() => this.refresh()))
|
||||
).pipe(
|
||||
switchMap(() => this.assignUser()),
|
||||
share()
|
||||
);
|
||||
|
||||
constructor(private loginService: LoginService, private tokenService: TokenService) {}
|
||||
|
||||
init() {
|
||||
return new Promise<void>(resolve => this.change$.subscribe(() => resolve()));
|
||||
}
|
||||
|
||||
change() {
|
||||
return this.change$;
|
||||
}
|
||||
|
||||
check() {
|
||||
return this.tokenService.valid();
|
||||
}
|
||||
|
||||
login(username: string, password: string, rememberMe = false) {
|
||||
return this.loginService.login(username, password, rememberMe).pipe(
|
||||
tap(token => this.tokenService.set(token)),
|
||||
map(() => this.check())
|
||||
);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
return this.loginService
|
||||
.refresh(filterObject({ refresh_token: this.tokenService.getRefreshToken() }))
|
||||
.pipe(
|
||||
catchError(() => of(undefined)),
|
||||
tap(token => this.tokenService.set(token)),
|
||||
map(() => this.check())
|
||||
);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this.loginService.logout().pipe(
|
||||
tap(() => this.tokenService.clear()),
|
||||
map(() => !this.check())
|
||||
);
|
||||
}
|
||||
|
||||
user() {
|
||||
return this.user$.pipe(share());
|
||||
}
|
||||
|
||||
menu() {
|
||||
return iif(() => this.check(), this.loginService.menu(), of([]));
|
||||
}
|
||||
|
||||
private assignUser() {
|
||||
if (!this.check()) {
|
||||
return of({}).pipe(tap(user => this.user$.next(user)));
|
||||
}
|
||||
|
||||
if (!isEmptyObject(this.user$.getValue())) {
|
||||
return of(this.user$.getValue());
|
||||
}
|
||||
|
||||
return this.loginService.me().pipe(tap(user => this.user$.next(user)));
|
||||
}
|
||||
}
|
||||
57
front/app/src/app/core/authentication/helpers.ts
Normal file
57
front/app/src/app/core/authentication/helpers.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { fromByteArray, toByteArray } from 'base64-js';
|
||||
|
||||
export class Base64 {
|
||||
static encode(plainText: string): string {
|
||||
return fromByteArray(pack(plainText)).replace(/[+/=]/g, m => {
|
||||
return { '+': '-', '/': '_', '=': '' }[m] as string;
|
||||
});
|
||||
}
|
||||
|
||||
static decode(b64: string): string {
|
||||
b64 = b64.replace(/[-_]/g, m => {
|
||||
return { '-': '+', '_': '/' }[m] as string;
|
||||
});
|
||||
while (b64.length % 4) {
|
||||
b64 += '=';
|
||||
}
|
||||
|
||||
return unpack(toByteArray(b64));
|
||||
}
|
||||
}
|
||||
|
||||
export function pack(str: string) {
|
||||
const bytes: any = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
bytes.push(...[str.charCodeAt(i)]);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function unpack(byteArray: any) {
|
||||
return String.fromCharCode(...byteArray);
|
||||
}
|
||||
|
||||
export const base64 = { encode: Base64.encode, decode: Base64.decode };
|
||||
|
||||
export function capitalize(text: string): string {
|
||||
return text.substring(0, 1).toUpperCase() + text.substring(1, text.length).toLowerCase();
|
||||
}
|
||||
|
||||
export function currentTimestamp(): number {
|
||||
return Math.ceil(new Date().getTime() / 1000);
|
||||
}
|
||||
|
||||
export function timeLeft(expiredAt: number): number {
|
||||
return Math.max(0, expiredAt - currentTimestamp());
|
||||
}
|
||||
|
||||
export function filterObject<T extends Record<string, unknown>>(obj: T) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([, value]) => value !== undefined && value !== null)
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmptyObject(obj: Record<string, any>) {
|
||||
return Object.keys(obj).length === 0;
|
||||
}
|
||||
9
front/app/src/app/core/authentication/index.ts
Normal file
9
front/app/src/app/core/authentication/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from './interface';
|
||||
export * from './auth.guard';
|
||||
export * from './auth.service';
|
||||
export * from './token-factory.service';
|
||||
export * from './token.service';
|
||||
export * from './token';
|
||||
export * from './login.service';
|
||||
export * from './user';
|
||||
export * from './helpers';
|
||||
20
front/app/src/app/core/authentication/interface.ts
Normal file
20
front/app/src/app/core/authentication/interface.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface User {
|
||||
[prop: string]: any;
|
||||
|
||||
id?: number | string | null;
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
roles?: any[];
|
||||
permissions?: any[];
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
[prop: string]: any;
|
||||
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
exp?: number;
|
||||
refresh_token?: string;
|
||||
}
|
||||
32
front/app/src/app/core/authentication/login.service.ts
Normal file
32
front/app/src/app/core/authentication/login.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Token, User } from './interface';
|
||||
import { Menu } from '@core';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LoginService {
|
||||
constructor(protected http: HttpClient) {}
|
||||
|
||||
login(username: string, password: string, rememberMe = false) {
|
||||
return this.http.post<Token>('/auth/login', { username, password, rememberMe });
|
||||
}
|
||||
|
||||
refresh(params: Record<string, any>) {
|
||||
return this.http.post<Token>('/auth/refresh', params);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this.http.post<any>('/auth/logout', {});
|
||||
}
|
||||
|
||||
me() {
|
||||
return this.http.get<User>('/me');
|
||||
}
|
||||
|
||||
menu() {
|
||||
return this.http.get<{ menu: Menu[] }>('/me/menu').pipe(map(res => res.menu));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Token } from './interface';
|
||||
import { SimpleToken, JwtToken, BaseToken } from './token';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TokenFactory {
|
||||
create(attributes: Token): BaseToken | undefined {
|
||||
if (!attributes.access_token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (JwtToken.is(attributes.access_token)) {
|
||||
return new JwtToken(attributes);
|
||||
}
|
||||
|
||||
return new SimpleToken(attributes);
|
||||
}
|
||||
}
|
||||
52
front/app/src/app/core/authentication/token.service.spec.ts
Normal file
52
front/app/src/app/core/authentication/token.service.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { MemoryStorageService, LocalStorageService } from '@shared/services/storage.service';
|
||||
import { TokenService, currentTimestamp, TokenFactory, SimpleToken } from '@core/authentication';
|
||||
|
||||
describe('TokenService', () => {
|
||||
let tokenService: TokenService;
|
||||
let tokenFactory: TokenFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: LocalStorageService, useClass: MemoryStorageService }],
|
||||
});
|
||||
tokenService = TestBed.inject(TokenService);
|
||||
tokenFactory = TestBed.inject(TokenFactory);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(tokenService).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should get authorization header value', () => {
|
||||
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||
|
||||
expect(tokenService.getBearerToken()).toEqual('Bearer token');
|
||||
});
|
||||
|
||||
it('cannot get authorization header value', () => {
|
||||
tokenService.set({ access_token: '', token_type: 'bearer' });
|
||||
|
||||
expect(tokenService.getBearerToken()).toBe('');
|
||||
});
|
||||
|
||||
it('should not has exp when token has expires_in', () => {
|
||||
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||
|
||||
tokenService
|
||||
.change()
|
||||
.pipe(tap(token => expect(token!.exp).toBeUndefined()))
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
it('should has exp when token has expires_in', () => {
|
||||
const expiresIn = 3600;
|
||||
tokenService.set({ access_token: 'token', token_type: 'bearer', expires_in: expiresIn });
|
||||
|
||||
tokenService
|
||||
.change()
|
||||
.pipe(tap(token => expect(token!.exp).toEqual(currentTimestamp() + expiresIn)))
|
||||
.subscribe();
|
||||
});
|
||||
});
|
||||
99
front/app/src/app/core/authentication/token.service.ts
Normal file
99
front/app/src/app/core/authentication/token.service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, Subject, Subscription, timer } from 'rxjs';
|
||||
import { share } from 'rxjs/operators';
|
||||
import { LocalStorageService } from '@shared';
|
||||
import { Token } from './interface';
|
||||
import { BaseToken } from './token';
|
||||
import { TokenFactory } from './token-factory.service';
|
||||
import { currentTimestamp, filterObject } from './helpers';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TokenService implements OnDestroy {
|
||||
private key = 'ng-matero-token';
|
||||
|
||||
private change$ = new BehaviorSubject<BaseToken | undefined>(undefined);
|
||||
private refresh$ = new Subject<BaseToken | undefined>();
|
||||
private timer$?: Subscription;
|
||||
|
||||
private _token?: BaseToken;
|
||||
|
||||
constructor(private store: LocalStorageService, private factory: TokenFactory) {}
|
||||
|
||||
private get token(): BaseToken | undefined {
|
||||
if (!this._token) {
|
||||
this._token = this.factory.create(this.store.get(this.key));
|
||||
}
|
||||
|
||||
return this._token;
|
||||
}
|
||||
|
||||
change(): Observable<BaseToken | undefined> {
|
||||
return this.change$.pipe(share());
|
||||
}
|
||||
|
||||
refresh(): Observable<BaseToken | undefined> {
|
||||
this.buildRefresh();
|
||||
|
||||
return this.refresh$.pipe(share());
|
||||
}
|
||||
|
||||
set(token?: Token): TokenService {
|
||||
this.save(token);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.save();
|
||||
}
|
||||
|
||||
valid(): boolean {
|
||||
return this.token?.valid() ?? false;
|
||||
}
|
||||
|
||||
getBearerToken(): string {
|
||||
return this.token?.getBearerToken() ?? '';
|
||||
}
|
||||
|
||||
getRefreshToken(): string | void {
|
||||
return this.token?.refresh_token;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearRefresh();
|
||||
}
|
||||
|
||||
private save(token?: Token): void {
|
||||
this._token = undefined;
|
||||
|
||||
if (!token) {
|
||||
this.store.remove(this.key);
|
||||
} else {
|
||||
const value = Object.assign({ access_token: '', token_type: 'Bearer' }, token, {
|
||||
exp: token.expires_in ? currentTimestamp() + token.expires_in : null,
|
||||
});
|
||||
this.store.set(this.key, filterObject(value));
|
||||
}
|
||||
|
||||
this.change$.next(this.token);
|
||||
this.buildRefresh();
|
||||
}
|
||||
|
||||
private buildRefresh() {
|
||||
this.clearRefresh();
|
||||
|
||||
if (this.token?.needRefresh()) {
|
||||
this.timer$ = timer(this.token.getRefreshTime() * 1000).subscribe(() => {
|
||||
this.refresh$.next(this.token);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearRefresh() {
|
||||
if (this.timer$ && !this.timer$.closed) {
|
||||
this.timer$.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
41
front/app/src/app/core/authentication/token.spec.ts
Normal file
41
front/app/src/app/core/authentication/token.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { base64, currentTimestamp, JwtToken } from '@core/authentication';
|
||||
|
||||
describe('Token', () => {
|
||||
describe('JwtToken', () => {
|
||||
function generateToken(params: any, typ = 'JWT') {
|
||||
return [
|
||||
base64.encode(JSON.stringify({ typ, alg: 'HS256' })),
|
||||
base64.encode(JSON.stringify(params)),
|
||||
base64.encode('ng-matero'),
|
||||
].join('.');
|
||||
}
|
||||
|
||||
const exp = currentTimestamp() + 3600;
|
||||
const token = new JwtToken({
|
||||
access_token: generateToken({ exp }, 'at+JWT'),
|
||||
token_type: 'Bearer',
|
||||
});
|
||||
|
||||
it('test access_token is JWT', () => {
|
||||
expect(JwtToken.is(token.access_token)).toBeTrue();
|
||||
});
|
||||
|
||||
it('test bearer token', function () {
|
||||
expect(token.getBearerToken()).toBe(`Bearer ${token.access_token}`);
|
||||
});
|
||||
|
||||
it('test payload has exp attribute', () => {
|
||||
expect(token.exp).toEqual(exp);
|
||||
});
|
||||
|
||||
it('test payload does not has exp attribute', () => {
|
||||
expect(token.exp).toEqual(exp);
|
||||
});
|
||||
|
||||
it('test does not has exp attribute', () => {
|
||||
const token = new JwtToken({ access_token: generateToken({}), token_type: 'Bearer' });
|
||||
|
||||
expect(token.exp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
87
front/app/src/app/core/authentication/token.ts
Normal file
87
front/app/src/app/core/authentication/token.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { base64, capitalize, currentTimestamp, timeLeft } from './helpers';
|
||||
import { Token } from './interface';
|
||||
|
||||
export abstract class BaseToken {
|
||||
constructor(protected attributes: Token) {}
|
||||
|
||||
get access_token(): string {
|
||||
return this.attributes.access_token;
|
||||
}
|
||||
|
||||
get refresh_token(): string | void {
|
||||
return this.attributes.refresh_token;
|
||||
}
|
||||
|
||||
get token_type(): string {
|
||||
return this.attributes.token_type ?? 'bearer';
|
||||
}
|
||||
|
||||
get exp(): number | void {
|
||||
return this.attributes.exp;
|
||||
}
|
||||
|
||||
valid(): boolean {
|
||||
return this.hasAccessToken() && !this.isExpired();
|
||||
}
|
||||
|
||||
getBearerToken(): string {
|
||||
return this.access_token
|
||||
? [capitalize(this.token_type), this.access_token].join(' ').trim()
|
||||
: '';
|
||||
}
|
||||
|
||||
needRefresh(): boolean {
|
||||
return this.exp !== undefined && this.exp >= 0;
|
||||
}
|
||||
|
||||
getRefreshTime(): number {
|
||||
return timeLeft((this.exp ?? 0) - 5);
|
||||
}
|
||||
|
||||
private hasAccessToken(): boolean {
|
||||
return !!this.access_token;
|
||||
}
|
||||
|
||||
private isExpired(): boolean {
|
||||
return this.exp !== undefined && this.exp - currentTimestamp() <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class SimpleToken extends BaseToken {}
|
||||
|
||||
export class JwtToken extends SimpleToken {
|
||||
private _payload?: { exp?: number | void };
|
||||
|
||||
static is(accessToken: string): boolean {
|
||||
try {
|
||||
const [_header] = accessToken.split('.');
|
||||
const header = JSON.parse(base64.decode(_header));
|
||||
|
||||
return header.typ.toUpperCase().includes('JWT');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
get exp(): number | void {
|
||||
return this.payload?.exp;
|
||||
}
|
||||
|
||||
private get payload(): { exp?: number | void } {
|
||||
if (!this.access_token) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (this._payload) {
|
||||
return this._payload;
|
||||
}
|
||||
|
||||
const [, payload] = this.access_token.split('.');
|
||||
const data = JSON.parse(base64.decode(payload));
|
||||
if (!data.exp) {
|
||||
data.exp = this.attributes.exp;
|
||||
}
|
||||
|
||||
return (this._payload = data);
|
||||
}
|
||||
}
|
||||
14
front/app/src/app/core/authentication/user.ts
Normal file
14
front/app/src/app/core/authentication/user.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { User } from './interface';
|
||||
|
||||
export const admin: User = {
|
||||
id: 1,
|
||||
name: 'Zongbin',
|
||||
email: 'nzb329@163.com',
|
||||
avatar: './assets/images/avatar.jpg',
|
||||
};
|
||||
|
||||
export const guest: User = {
|
||||
name: 'unknown',
|
||||
email: 'unknown',
|
||||
avatar: './assets/images/avatar-default.jpg',
|
||||
};
|
||||
3
front/app/src/app/core/bootstrap/README.md
Normal file
3
front/app/src/app/core/bootstrap/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Bootstrap
|
||||
|
||||
The services in this folder should be singletons and used for sharing data and functionality.
|
||||
150
front/app/src/app/core/bootstrap/menu.service.ts
Normal file
150
front/app/src/app/core/bootstrap/menu.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { share } from 'rxjs/operators';
|
||||
|
||||
export interface MenuTag {
|
||||
color: string; // background color
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface MenuPermissions {
|
||||
only?: string | string[];
|
||||
except?: string | string[];
|
||||
}
|
||||
|
||||
export interface MenuChildrenItem {
|
||||
route: string;
|
||||
name: string;
|
||||
type: 'link' | 'sub' | 'extLink' | 'extTabLink';
|
||||
children?: MenuChildrenItem[];
|
||||
permissions?: MenuPermissions;
|
||||
}
|
||||
|
||||
export interface Menu {
|
||||
route: string;
|
||||
name: string;
|
||||
type: 'link' | 'sub' | 'extLink' | 'extTabLink';
|
||||
icon: string;
|
||||
label?: MenuTag;
|
||||
badge?: MenuTag;
|
||||
children?: MenuChildrenItem[];
|
||||
permissions?: MenuPermissions;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MenuService {
|
||||
private menu$: BehaviorSubject<Menu[]> = new BehaviorSubject<Menu[]>([]);
|
||||
|
||||
/** Get all the menu data. */
|
||||
getAll(): Observable<Menu[]> {
|
||||
return this.menu$.asObservable();
|
||||
}
|
||||
|
||||
/** Observe the change of menu data. */
|
||||
change(): Observable<Menu[]> {
|
||||
return this.menu$.pipe(share());
|
||||
}
|
||||
|
||||
/** Initialize the menu data. */
|
||||
set(menu: Menu[]): Observable<Menu[]> {
|
||||
this.menu$.next(menu);
|
||||
return this.menu$.asObservable();
|
||||
}
|
||||
|
||||
/** Add one item to the menu data. */
|
||||
add(menu: Menu) {
|
||||
const tmpMenu = this.menu$.value;
|
||||
tmpMenu.push(menu);
|
||||
this.menu$.next(tmpMenu);
|
||||
}
|
||||
|
||||
/** Reset the menu data. */
|
||||
reset() {
|
||||
this.menu$.next([]);
|
||||
}
|
||||
|
||||
/** Delete empty values and rebuild route. */
|
||||
buildRoute(routeArr: string[]): string {
|
||||
let route = '';
|
||||
routeArr.forEach(item => {
|
||||
if (item && item.trim()) {
|
||||
route += '/' + item.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
});
|
||||
return route;
|
||||
}
|
||||
|
||||
/** Get the menu item name based on current route. */
|
||||
getItemName(routeArr: string[]): string {
|
||||
return this.getLevel(routeArr)[routeArr.length - 1];
|
||||
}
|
||||
|
||||
// Whether is a leaf menu
|
||||
private isLeafItem(item: MenuChildrenItem): boolean {
|
||||
const cond0 = item.route === undefined;
|
||||
const cond1 = item.children === undefined;
|
||||
const cond2 = !cond1 && item.children?.length === 0;
|
||||
return cond0 || cond1 || cond2;
|
||||
}
|
||||
|
||||
// Deep clone object could be jsonized
|
||||
private deepClone(obj: any): any {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
// Whether two objects could be jsonized equal
|
||||
private isJsonObjEqual(obj0: any, obj1: any): boolean {
|
||||
return JSON.stringify(obj0) === JSON.stringify(obj1);
|
||||
}
|
||||
|
||||
// Whether routeArr equals realRouteArr (after remove empty route element)
|
||||
private isRouteEqual(routeArr: Array<string>, realRouteArr: Array<string>): boolean {
|
||||
realRouteArr = this.deepClone(realRouteArr);
|
||||
realRouteArr = realRouteArr.filter(r => r !== '');
|
||||
return this.isJsonObjEqual(routeArr, realRouteArr);
|
||||
}
|
||||
|
||||
/** Get the menu level. */
|
||||
getLevel(routeArr: string[]): string[] {
|
||||
let tmpArr: any[] = [];
|
||||
this.menu$.value.forEach(item => {
|
||||
// Breadth-first traverse
|
||||
let unhandledLayer = [{ item, parentNamePathList: [], realRouteArr: [] }];
|
||||
while (unhandledLayer.length > 0) {
|
||||
let nextUnhandledLayer: any[] = [];
|
||||
for (const ele of unhandledLayer) {
|
||||
const eachItem = ele.item;
|
||||
const currentNamePathList = this.deepClone(ele.parentNamePathList).concat(eachItem.name);
|
||||
const currentRealRouteArr = this.deepClone(ele.realRouteArr).concat(eachItem.route);
|
||||
// Compare the full Array for expandable
|
||||
if (this.isRouteEqual(routeArr, currentRealRouteArr)) {
|
||||
tmpArr = currentNamePathList;
|
||||
break;
|
||||
}
|
||||
if (!this.isLeafItem(eachItem)) {
|
||||
const wrappedChildren = eachItem.children?.map(child => ({
|
||||
item: child,
|
||||
parentNamePathList: currentNamePathList,
|
||||
realRouteArr: currentRealRouteArr,
|
||||
}));
|
||||
nextUnhandledLayer = nextUnhandledLayer.concat(wrappedChildren);
|
||||
}
|
||||
}
|
||||
unhandledLayer = nextUnhandledLayer;
|
||||
}
|
||||
});
|
||||
return tmpArr;
|
||||
}
|
||||
|
||||
/** Add namespace for translation. */
|
||||
addNamespace(menu: Menu[] | MenuChildrenItem[], namespace: string) {
|
||||
menu.forEach(menuItem => {
|
||||
menuItem.name = `${namespace}.${menuItem.name}`;
|
||||
if (menuItem.children && menuItem.children.length > 0) {
|
||||
this.addNamespace(menuItem.children, menuItem.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
28
front/app/src/app/core/bootstrap/preloader.service.ts
Normal file
28
front/app/src/app/core/bootstrap/preloader.service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Inject, Injectable } from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PreloaderService {
|
||||
private selector = 'globalLoader';
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) {}
|
||||
|
||||
private getElement() {
|
||||
return this.document.getElementById(this.selector);
|
||||
}
|
||||
|
||||
hide() {
|
||||
const el = this.getElement();
|
||||
if (el) {
|
||||
el.addEventListener('transitionend', () => {
|
||||
el.className = 'global-loader-hidden';
|
||||
});
|
||||
|
||||
if (!el.classList.contains('global-loader-hidden')) {
|
||||
el.className += ' global-loader-fade-in';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
78
front/app/src/app/core/bootstrap/sanctum.service.spec.ts
Normal file
78
front/app/src/app/core/bootstrap/sanctum.service.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { BASE_URL } from '../interceptors/base-url-interceptor';
|
||||
import { SANCTUM_PREFIX, SanctumService } from '@core';
|
||||
|
||||
describe('SanctumService', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
let sanctumService: SanctumService;
|
||||
|
||||
const setBaseUrlAndSanctumPrefix = (baseUrl: string | null, sanctumPrefix: string | null) => {
|
||||
TestBed.overrideProvider(BASE_URL, { useValue: baseUrl });
|
||||
TestBed.overrideProvider(SANCTUM_PREFIX, { useValue: sanctumPrefix });
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
sanctumService = TestBed.inject(SanctumService);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
{ provide: BASE_URL, useValue: null },
|
||||
{ provide: SANCTUM_PREFIX, useValue: null },
|
||||
SanctumService,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should get csrf cookie once', done => {
|
||||
setBaseUrlAndSanctumPrefix(null, null);
|
||||
|
||||
sanctumService.load().then(data => {
|
||||
expect(data).toEqual({ cookie: true });
|
||||
done();
|
||||
});
|
||||
|
||||
httpMock.expectOne('/sanctum/csrf-cookie').flush({ cookie: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with base url', done => {
|
||||
setBaseUrlAndSanctumPrefix('http://foo.bar/api', '');
|
||||
|
||||
sanctumService.load().then((data: any) => {
|
||||
expect(data).toEqual({ cookie: true });
|
||||
done();
|
||||
});
|
||||
|
||||
httpMock.expectOne('http://foo.bar/sanctum/csrf-cookie').flush({ cookie: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with sanctum prefix', done => {
|
||||
setBaseUrlAndSanctumPrefix(null, '/foobar/');
|
||||
|
||||
sanctumService.load().then((data: any) => {
|
||||
expect(data).toEqual({ cookie: true });
|
||||
done();
|
||||
});
|
||||
|
||||
httpMock.expectOne('/foobar/csrf-cookie').flush({ cookie: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with base url and sanctum prefix', done => {
|
||||
setBaseUrlAndSanctumPrefix('http://foo.bar/api/', '/foobar');
|
||||
|
||||
sanctumService.load().then((data: any) => {
|
||||
expect(data).toEqual({ cookie: true });
|
||||
done();
|
||||
});
|
||||
|
||||
httpMock.expectOne('http://foo.bar/foobar/csrf-cookie').flush({ cookie: true });
|
||||
});
|
||||
});
|
||||
38
front/app/src/app/core/bootstrap/sanctum.service.ts
Normal file
38
front/app/src/app/core/bootstrap/sanctum.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BASE_URL } from '../interceptors/base-url-interceptor';
|
||||
|
||||
export const SANCTUM_PREFIX = new InjectionToken<string>('SANCTUM_PREFIX');
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SanctumService {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
@Optional() @Inject(BASE_URL) private baseUrl?: string,
|
||||
@Optional() @Inject(SANCTUM_PREFIX) private prefix?: string
|
||||
) {}
|
||||
|
||||
load(): Promise<unknown> {
|
||||
return new Promise(resolve => this.toObservable().subscribe(resolve));
|
||||
}
|
||||
|
||||
toObservable(): Observable<any> {
|
||||
return this.http.get(this.getUrl());
|
||||
}
|
||||
|
||||
private getUrl(): string {
|
||||
const prefix = this.prefix || 'sanctum';
|
||||
const path = `/${prefix.replace(/^\/|\/$/g, '')}/csrf-cookie`;
|
||||
|
||||
if (!this.baseUrl) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const url = new URL(this.baseUrl);
|
||||
|
||||
return url.origin + path;
|
||||
}
|
||||
}
|
||||
34
front/app/src/app/core/bootstrap/settings.service.ts
Normal file
34
front/app/src/app/core/bootstrap/settings.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { AppSettings, defaults } from '../settings';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SettingsService {
|
||||
get notify(): Observable<Record<string, any>> {
|
||||
return this.notify$.asObservable();
|
||||
}
|
||||
|
||||
private notify$ = new BehaviorSubject<Record<string, any>>({});
|
||||
|
||||
getOptions() {
|
||||
return this.options;
|
||||
}
|
||||
|
||||
setOptions(options: AppSettings) {
|
||||
this.options = Object.assign(defaults, options);
|
||||
this.notify$.next(this.options);
|
||||
}
|
||||
|
||||
private options = defaults;
|
||||
|
||||
getLanguage() {
|
||||
return this.options.language;
|
||||
}
|
||||
|
||||
setLanguage(lang: string) {
|
||||
this.options.language = lang;
|
||||
this.notify$.next({ lang });
|
||||
}
|
||||
}
|
||||
87
front/app/src/app/core/bootstrap/startup.service.spec.ts
Normal file
87
front/app/src/app/core/bootstrap/startup.service.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { NgxPermissionsModule, NgxPermissionsService, NgxRolesService } from 'ngx-permissions';
|
||||
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||
import { admin, TokenService } from '@core/authentication';
|
||||
import { MenuService } from '@core/bootstrap/menu.service';
|
||||
import { StartupService } from '@core/bootstrap/startup.service';
|
||||
|
||||
describe('StartupService', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let startup: StartupService;
|
||||
let tokenService: TokenService;
|
||||
let menuService: MenuService;
|
||||
let mockPermissionsService: NgxPermissionsService;
|
||||
let mockRolesService: NgxRolesService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule, NgxPermissionsModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: LocalStorageService,
|
||||
useClass: MemoryStorageService,
|
||||
},
|
||||
{
|
||||
provide: NgxPermissionsService,
|
||||
useValue: {
|
||||
loadPermissions: (permissions: string[]) => void 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: NgxRolesService,
|
||||
useValue: {
|
||||
flushRoles: () => void 0,
|
||||
addRoles: (params: { ADMIN: string[] }) => void 0,
|
||||
},
|
||||
},
|
||||
StartupService,
|
||||
],
|
||||
});
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
startup = TestBed.inject(StartupService);
|
||||
tokenService = TestBed.inject(TokenService);
|
||||
menuService = TestBed.inject(MenuService);
|
||||
mockPermissionsService = TestBed.inject(NgxPermissionsService);
|
||||
mockRolesService = TestBed.inject(NgxRolesService);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should load menu when token changed and token valid', async () => {
|
||||
const menuData = { menu: [] };
|
||||
const permissions = ['canAdd', 'canDelete', 'canEdit', 'canRead'];
|
||||
spyOn(menuService, 'addNamespace');
|
||||
spyOn(menuService, 'set');
|
||||
spyOn(mockPermissionsService, 'loadPermissions');
|
||||
spyOn(mockRolesService, 'flushRoles');
|
||||
spyOn(mockRolesService, 'addRoles');
|
||||
|
||||
await startup.load();
|
||||
|
||||
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||
|
||||
httpMock.expectOne('/me').flush(admin);
|
||||
httpMock.expectOne('/me/menu').flush(menuData);
|
||||
|
||||
expect(menuService.addNamespace).toHaveBeenCalledWith(menuData.menu, 'menu');
|
||||
expect(menuService.set).toHaveBeenCalledWith(menuData.menu);
|
||||
expect(mockPermissionsService.loadPermissions).toHaveBeenCalledWith(permissions);
|
||||
expect(mockRolesService.flushRoles).toHaveBeenCalledWith();
|
||||
expect(mockRolesService.addRoles).toHaveBeenCalledWith({ ADMIN: permissions });
|
||||
});
|
||||
|
||||
it('should clear menu when token changed and token invalid', async () => {
|
||||
spyOn(menuService, 'addNamespace');
|
||||
spyOn(menuService, 'set');
|
||||
|
||||
await startup.load();
|
||||
|
||||
tokenService.set({ access_token: '', token_type: 'bearer' });
|
||||
|
||||
httpMock.expectNone('/me/menu');
|
||||
|
||||
expect(menuService.addNamespace).toHaveBeenCalledWith([], 'menu');
|
||||
expect(menuService.set).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
53
front/app/src/app/core/bootstrap/startup.service.ts
Normal file
53
front/app/src/app/core/bootstrap/startup.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import { NgxPermissionsService, NgxRolesService } from 'ngx-permissions';
|
||||
import { AuthService, User } from '@core/authentication';
|
||||
import { Menu, MenuService } from './menu.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StartupService {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private menuService: MenuService,
|
||||
private permissonsService: NgxPermissionsService,
|
||||
private rolesService: NgxRolesService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load the application only after get the menu or other essential informations
|
||||
* such as permissions and roles.
|
||||
*/
|
||||
load() {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.authService
|
||||
.change()
|
||||
.pipe(
|
||||
tap(user => this.setPermissions(user)),
|
||||
switchMap(() => this.authService.menu()),
|
||||
tap(menu => this.setMenu(menu))
|
||||
)
|
||||
.subscribe(
|
||||
() => resolve(),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private setMenu(menu: Menu[]) {
|
||||
this.menuService.addNamespace(menu, 'menu');
|
||||
this.menuService.set(menu);
|
||||
}
|
||||
|
||||
private setPermissions(user: User) {
|
||||
// In a real app, you should get permissions and roles from the user information.
|
||||
const permissions = ['canAdd', 'canDelete', 'canEdit', 'canRead'];
|
||||
this.permissonsService.loadPermissions(permissions);
|
||||
this.rolesService.flushRoles();
|
||||
this.rolesService.addRoles({ ADMIN: permissions });
|
||||
|
||||
// Tips: Alternatively you can add permissions with role at the same time.
|
||||
// this.rolesService.addRolesWithPermissions({ ADMIN: permissions });
|
||||
}
|
||||
}
|
||||
33
front/app/src/app/core/bootstrap/translate-lang.service.ts
Normal file
33
front/app/src/app/core/bootstrap/translate-lang.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, Injector } from '@angular/core';
|
||||
import { LOCATION_INITIALIZED } from '@angular/common';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TranslateLangService {
|
||||
constructor(
|
||||
private injector: Injector,
|
||||
private translate: TranslateService,
|
||||
private settings: SettingsService
|
||||
) {}
|
||||
|
||||
load() {
|
||||
return new Promise<void>(resolve => {
|
||||
const locationInitialized = this.injector.get(LOCATION_INITIALIZED, Promise.resolve());
|
||||
locationInitialized.then(() => {
|
||||
const browserLang = navigator.language;
|
||||
const defaultLang = browserLang.match(/en-US|zh-CN|zh-TW/) ? browserLang : 'en-US';
|
||||
|
||||
this.settings.setLanguage(defaultLang);
|
||||
this.translate.setDefaultLang(defaultLang);
|
||||
this.translate.use(defaultLang).subscribe(
|
||||
() => console.log(`Successfully initialized '${defaultLang}' language.'`),
|
||||
() => console.error(`Problem with '${defaultLang}' language initialization.'`),
|
||||
() => resolve()
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
13
front/app/src/app/core/core.module.ts
Normal file
13
front/app/src/app/core/core.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NgModule, Optional, SkipSelf } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { throwIfAlreadyLoaded } from './module-import-guard';
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
imports: [CommonModule],
|
||||
})
|
||||
export class CoreModule {
|
||||
constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
|
||||
throwIfAlreadyLoaded(parentModule, 'CoreModule');
|
||||
}
|
||||
}
|
||||
16
front/app/src/app/core/index.ts
Normal file
16
front/app/src/app/core/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export * from './settings';
|
||||
export * from './initializers';
|
||||
|
||||
// Bootstrap
|
||||
export * from './bootstrap/menu.service';
|
||||
export * from './bootstrap/settings.service';
|
||||
export * from './bootstrap/startup.service';
|
||||
export * from './bootstrap/preloader.service';
|
||||
export * from './bootstrap/translate-lang.service';
|
||||
export * from './bootstrap/sanctum.service';
|
||||
|
||||
// Interceptors
|
||||
export * from './interceptors';
|
||||
|
||||
// Authentication
|
||||
export * from './authentication';
|
||||
37
front/app/src/app/core/initializers.ts
Normal file
37
front/app/src/app/core/initializers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { APP_INITIALIZER } from '@angular/core';
|
||||
|
||||
// import { SanctumService } from './bootstrap/sanctum.service';
|
||||
// export function SanctumServiceFactory(sanctumService: SanctumService) {
|
||||
// return () => sanctumService.load();
|
||||
// }
|
||||
|
||||
import { TranslateLangService } from './bootstrap/translate-lang.service';
|
||||
export function TranslateLangServiceFactory(translateLangService: TranslateLangService) {
|
||||
return () => translateLangService.load();
|
||||
}
|
||||
|
||||
import { StartupService } from './bootstrap/startup.service';
|
||||
export function StartupServiceFactory(startupService: StartupService) {
|
||||
return () => startupService.load();
|
||||
}
|
||||
|
||||
export const appInitializerProviders = [
|
||||
// {
|
||||
// provide: APP_INITIALIZER,
|
||||
// useFactory: SanctumServiceFactory,
|
||||
// deps: [SanctumService],
|
||||
// multi: true,
|
||||
// },
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: TranslateLangServiceFactory,
|
||||
deps: [TranslateLangService],
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: StartupServiceFactory,
|
||||
deps: [StartupService],
|
||||
multi: true,
|
||||
},
|
||||
];
|
||||
3
front/app/src/app/core/interceptors/README.md
Normal file
3
front/app/src/app/core/interceptors/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Interceptors
|
||||
|
||||
https://angular.io/guide/http#intercepting-requests-and-responses
|
||||
@@ -0,0 +1,47 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { BASE_URL, BaseUrlInterceptor } from './base-url-interceptor';
|
||||
|
||||
describe('BaseUrlInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
const baseUrl = 'https://foo.bar';
|
||||
|
||||
const setBaseUrl = (url: string | null) => {
|
||||
TestBed.overrideProvider(BASE_URL, { useValue: url });
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
{ provide: BASE_URL, useValue: null },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: BaseUrlInterceptor, multi: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should not prepend base url when base url is empty', () => {
|
||||
setBaseUrl(null);
|
||||
|
||||
http.get('/me').subscribe(data => expect(data).toEqual({ success: true }));
|
||||
|
||||
httpMock.expectOne('/me').flush({ success: true });
|
||||
});
|
||||
|
||||
it('should prepend base url when request url does not has http scheme', () => {
|
||||
setBaseUrl(baseUrl);
|
||||
|
||||
http.get('./me').subscribe(data => expect(data).toEqual({ success: true }));
|
||||
httpMock.expectOne(baseUrl + '/me').flush({ success: true });
|
||||
|
||||
http.get('').subscribe(data => expect(data).toEqual({ success: true }));
|
||||
httpMock.expectOne(baseUrl).flush({ success: true });
|
||||
});
|
||||
});
|
||||
24
front/app/src/app/core/interceptors/base-url-interceptor.ts
Normal file
24
front/app/src/app/core/interceptors/base-url-interceptor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export const BASE_URL = new InjectionToken<string>('BASE_URL');
|
||||
|
||||
@Injectable()
|
||||
export class BaseUrlInterceptor implements HttpInterceptor {
|
||||
private hasScheme = (url: string) => this.baseUrl && new RegExp('^http(s)?://', 'i').test(url);
|
||||
|
||||
constructor(@Optional() @Inject(BASE_URL) private baseUrl?: string) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
return this.hasScheme(request.url) === false
|
||||
? next.handle(request.clone({ url: this.prependBaseUrl(request.url) }))
|
||||
: next.handle(request);
|
||||
}
|
||||
|
||||
private prependBaseUrl(url: string) {
|
||||
return [this.baseUrl?.replace(/\/$/g, ''), url.replace(/^\.?\//, '')]
|
||||
.filter(val => val)
|
||||
.join('/');
|
||||
}
|
||||
}
|
||||
41
front/app/src/app/core/interceptors/default-interceptor.ts
Normal file
41
front/app/src/app/core/interceptors/default-interceptor.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
} from '@angular/common/http';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { mergeMap } from 'rxjs/operators';
|
||||
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
||||
@Injectable()
|
||||
export class DefaultInterceptor implements HttpInterceptor {
|
||||
constructor(private toast: ToastrService) {}
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
if (!req.url.includes('/api/')) {
|
||||
return next.handle(req);
|
||||
}
|
||||
|
||||
return next.handle(req).pipe(mergeMap((event: HttpEvent<any>) => this.handleOkReq(event)));
|
||||
}
|
||||
|
||||
private handleOkReq(event: HttpEvent<any>): Observable<any> {
|
||||
if (event instanceof HttpResponse) {
|
||||
const body: any = event.body;
|
||||
// failure: { code: **, msg: 'failure' }
|
||||
// success: { code: 0, msg: 'success', data: {} }
|
||||
if (body && 'code' in body && body.code !== 0) {
|
||||
if (body.msg) {
|
||||
this.toast.error(body.msg);
|
||||
}
|
||||
return throwError([]);
|
||||
}
|
||||
}
|
||||
// Pass down event if everything is OK
|
||||
return of(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { ToastrModule, ToastrService } from 'ngx-toastr';
|
||||
import { ErrorInterceptor } from './error-interceptor';
|
||||
|
||||
describe('ErrorInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
let router: Router;
|
||||
let toast: ToastrService;
|
||||
const emptyFn = () => {};
|
||||
|
||||
function assertStatus(status: number, statusText: string) {
|
||||
spyOn(router, 'navigateByUrl');
|
||||
|
||||
http.get('/me').subscribe(emptyFn, emptyFn, emptyFn);
|
||||
|
||||
httpMock.expectOne('/me').flush({}, { status, statusText });
|
||||
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith(`/${status}`, {
|
||||
skipLocationChange: true,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule, RouterTestingModule, ToastrModule.forRoot()],
|
||||
providers: [{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }],
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
router = TestBed.inject(Router);
|
||||
toast = TestBed.inject(ToastrService);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should handle status code 401', () => {
|
||||
spyOn(router, 'navigateByUrl');
|
||||
spyOn(toast, 'error');
|
||||
|
||||
http.get('/me').subscribe(emptyFn, emptyFn, emptyFn);
|
||||
httpMock.expectOne('/me').flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('401 Unauthorized');
|
||||
expect(router.navigateByUrl).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
|
||||
it('should handle status code 403', () => {
|
||||
assertStatus(403, 'Forbidden');
|
||||
});
|
||||
|
||||
it('should handle status code 404', () => {
|
||||
assertStatus(404, 'Not Found');
|
||||
});
|
||||
|
||||
it('should handle status code 500', () => {
|
||||
assertStatus(500, 'Internal Server Error');
|
||||
});
|
||||
|
||||
it('should handle others status code', () => {
|
||||
spyOn(toast, 'error');
|
||||
|
||||
http.get('/me').subscribe(emptyFn, emptyFn, emptyFn);
|
||||
|
||||
httpMock.expectOne('/me').flush({}, { status: 504, statusText: 'Gateway Timeout' });
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('504 Gateway Timeout');
|
||||
});
|
||||
});
|
||||
60
front/app/src/app/core/interceptors/error-interceptor.ts
Normal file
60
front/app/src/app/core/interceptors/error-interceptor.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
||||
export enum STATUS {
|
||||
UNAUTHORIZED = 401,
|
||||
FORBIDDEN = 403,
|
||||
NOT_FOUND = 404,
|
||||
INTERNAL_SERVER_ERROR = 500,
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ErrorInterceptor implements HttpInterceptor {
|
||||
private errorPages = [STATUS.FORBIDDEN, STATUS.NOT_FOUND, STATUS.INTERNAL_SERVER_ERROR];
|
||||
|
||||
private getMessage = (error: HttpErrorResponse) => {
|
||||
if (error.error?.message) {
|
||||
return error.error.message;
|
||||
}
|
||||
|
||||
if (error.error?.msg) {
|
||||
return error.error.msg;
|
||||
}
|
||||
|
||||
return `${error.status} ${error.statusText}`;
|
||||
};
|
||||
|
||||
constructor(private router: Router, private toast: ToastrService) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
return next
|
||||
.handle(request)
|
||||
.pipe(catchError((error: HttpErrorResponse) => this.handleError(error)));
|
||||
}
|
||||
|
||||
private handleError(error: HttpErrorResponse) {
|
||||
if (this.errorPages.includes(error.status)) {
|
||||
this.router.navigateByUrl(`/${error.status}`, {
|
||||
skipLocationChange: true,
|
||||
});
|
||||
} else {
|
||||
console.error('ERROR', error);
|
||||
this.toast.error(this.getMessage(error));
|
||||
if (error.status === STATUS.UNAUTHORIZED) {
|
||||
this.router.navigateByUrl('/auth/login');
|
||||
}
|
||||
}
|
||||
|
||||
return throwError(error);
|
||||
}
|
||||
}
|
||||
31
front/app/src/app/core/interceptors/index.ts
Normal file
31
front/app/src/app/core/interceptors/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
|
||||
import { NoopInterceptor } from './noop-interceptor';
|
||||
// import { SanctumInterceptor } from './sanctum-interceptor';
|
||||
import { BaseUrlInterceptor } from './base-url-interceptor';
|
||||
import { SettingsInterceptor } from './settings-interceptor';
|
||||
import { TokenInterceptor } from './token-interceptor';
|
||||
import { DefaultInterceptor } from './default-interceptor';
|
||||
import { ErrorInterceptor } from './error-interceptor';
|
||||
import { LoggingInterceptor } from './logging-interceptor';
|
||||
|
||||
export * from './noop-interceptor';
|
||||
// export * from './sanctum-interceptor';
|
||||
export * from './base-url-interceptor';
|
||||
export * from './settings-interceptor';
|
||||
export * from './token-interceptor';
|
||||
export * from './default-interceptor';
|
||||
export * from './error-interceptor';
|
||||
export * from './logging-interceptor';
|
||||
|
||||
/** Http interceptor providers in outside-in order */
|
||||
export const httpInterceptorProviders = [
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },
|
||||
// { provide: HTTP_INTERCEPTORS, useClass: SanctumInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: BaseUrlInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: SettingsInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: DefaultInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
|
||||
];
|
||||
30
front/app/src/app/core/interceptors/logging-interceptor.ts
Normal file
30
front/app/src/app/core/interceptors/logging-interceptor.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpInterceptor, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http';
|
||||
import { finalize, tap } from 'rxjs/operators';
|
||||
import { MessageService } from '@shared';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements HttpInterceptor {
|
||||
constructor(private messenger: MessageService) {}
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler) {
|
||||
const started = Date.now();
|
||||
let ok: string;
|
||||
|
||||
// extend server response observable with logging
|
||||
return next.handle(req).pipe(
|
||||
tap(
|
||||
// Succeeds when there is a response; ignore other events
|
||||
event => (ok = event instanceof HttpResponse ? 'succeeded' : ''),
|
||||
// Operation failed; error is an HttpErrorResponse
|
||||
error => (ok = 'failed')
|
||||
),
|
||||
// Log when response observable either completes or errors
|
||||
finalize(() => {
|
||||
const elapsed = Date.now() - started;
|
||||
const msg = `${req.method} "${req.urlWithParams}" ${ok} in ${elapsed} ms.`;
|
||||
this.messenger.add(msg);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
10
front/app/src/app/core/interceptors/noop-interceptor.ts
Normal file
10
front/app/src/app/core/interceptors/noop-interceptor.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class NoopInterceptor implements HttpInterceptor {
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
return next.handle(req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { SanctumInterceptor } from './sanctum-interceptor';
|
||||
import { BASE_URL } from './base-url-interceptor';
|
||||
import { SANCTUM_PREFIX } from '@core/bootstrap/sanctum.service';
|
||||
|
||||
describe('SanctumInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
|
||||
const setBaseUrlAndSanctumPrefix = (baseUrl: string | null, sanctumPrefix: string | null) => {
|
||||
TestBed.overrideProvider(BASE_URL, { useValue: baseUrl });
|
||||
TestBed.overrideProvider(SANCTUM_PREFIX, { useValue: sanctumPrefix });
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
{ provide: BASE_URL, useValue: null },
|
||||
{ provide: SANCTUM_PREFIX, useValue: null },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: SanctumInterceptor, multi: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should get csrf cookie once', () => {
|
||||
setBaseUrlAndSanctumPrefix(null, null);
|
||||
|
||||
http
|
||||
.post('/auth/login', {
|
||||
username: 'foo',
|
||||
password: 'bar',
|
||||
})
|
||||
.pipe(switchMap(() => http.get('/me')))
|
||||
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||
|
||||
httpMock.expectOne('/sanctum/csrf-cookie').flush({ cookie: true });
|
||||
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||
httpMock.expectOne('/me').flush({ me: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with base url', () => {
|
||||
setBaseUrlAndSanctumPrefix('https://foo.bar/api', null);
|
||||
|
||||
http
|
||||
.post('/auth/login', {
|
||||
username: 'foo',
|
||||
password: 'bar',
|
||||
})
|
||||
.pipe(switchMap(() => http.get('/me')))
|
||||
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||
|
||||
httpMock.expectOne('https://foo.bar/sanctum/csrf-cookie').flush({ cookie: true });
|
||||
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||
httpMock.expectOne('/me').flush({ me: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with sanctum prefix', () => {
|
||||
setBaseUrlAndSanctumPrefix(null, 'foobar');
|
||||
|
||||
http
|
||||
.post('/auth/login', {
|
||||
username: 'foo',
|
||||
password: 'bar',
|
||||
})
|
||||
.pipe(switchMap(() => http.get('/me')))
|
||||
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||
|
||||
httpMock.expectOne('/foobar/csrf-cookie').flush({ cookie: true });
|
||||
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||
httpMock.expectOne('/me').flush({ me: true });
|
||||
});
|
||||
|
||||
it('should get csrf cookie with base url and sanctum prefix', () => {
|
||||
setBaseUrlAndSanctumPrefix('https://foo.bar/api', 'foobar');
|
||||
|
||||
http
|
||||
.post('/auth/login', {
|
||||
username: 'foo',
|
||||
password: 'bar',
|
||||
})
|
||||
.pipe(switchMap(() => http.get('/me')))
|
||||
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||
|
||||
httpMock.expectOne('https://foo.bar/foobar/csrf-cookie').flush({ cookie: true });
|
||||
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||
httpMock.expectOne('/me').flush({ me: true });
|
||||
});
|
||||
});
|
||||
22
front/app/src/app/core/interceptors/sanctum-interceptor.ts
Normal file
22
front/app/src/app/core/interceptors/sanctum-interceptor.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { SanctumService } from '@core';
|
||||
|
||||
@Injectable()
|
||||
export class SanctumInterceptor implements HttpInterceptor {
|
||||
private ready = false;
|
||||
|
||||
constructor(private sanctum: SanctumService) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
if (!this.ready) {
|
||||
this.ready = true;
|
||||
|
||||
return this.sanctum.toObservable().pipe(switchMap(() => next.handle(request)));
|
||||
}
|
||||
|
||||
return next.handle(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { SettingsService } from '@core/bootstrap/settings.service';
|
||||
import { SettingsInterceptor } from './settings-interceptor';
|
||||
|
||||
describe('SettingsInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
let settings: SettingsService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [{ provide: HTTP_INTERCEPTORS, useClass: SettingsInterceptor, multi: true }],
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
settings = TestBed.inject(SettingsService);
|
||||
});
|
||||
|
||||
it('should set accept language', () => {
|
||||
settings.setLanguage('zh-TW');
|
||||
|
||||
http.get('/me').subscribe();
|
||||
const testRequest = httpMock.expectOne('/me');
|
||||
testRequest.flush({ me: true });
|
||||
|
||||
expect(testRequest.request.headers.get('Accept-Language')).toEqual('zh-TW');
|
||||
});
|
||||
});
|
||||
17
front/app/src/app/core/interceptors/settings-interceptor.ts
Normal file
17
front/app/src/app/core/interceptors/settings-interceptor.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SettingsService } from '@core';
|
||||
|
||||
@Injectable()
|
||||
export class SettingsInterceptor implements HttpInterceptor {
|
||||
constructor(private settings: SettingsService) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
return next.handle(
|
||||
request.clone({
|
||||
headers: request.headers.append('Accept-Language', this.settings.getLanguage()),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
116
front/app/src/app/core/interceptors/token-interceptor.spec.ts
Normal file
116
front/app/src/app/core/interceptors/token-interceptor.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { TokenInterceptor } from './token-interceptor';
|
||||
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { STATUS } from 'angular-in-memory-web-api';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||
import { TokenService, User } from '@core/authentication';
|
||||
import { BASE_URL } from './base-url-interceptor';
|
||||
|
||||
describe('TokenInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let http: HttpClient;
|
||||
let router: Router;
|
||||
let tokenService: TokenService;
|
||||
const emptyFn = () => {};
|
||||
const baseUrl = 'https://foo.bar';
|
||||
const user: User = { id: 1, email: 'foo@bar.com' };
|
||||
|
||||
function init(url: string, access_token: string) {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule, RouterTestingModule],
|
||||
providers: [
|
||||
{ provide: LocalStorageService, useClass: MemoryStorageService },
|
||||
{ provide: BASE_URL, useValue: url },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true },
|
||||
],
|
||||
});
|
||||
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
http = TestBed.inject(HttpClient);
|
||||
router = TestBed.inject(Router);
|
||||
tokenService = TestBed.inject(TokenService).set({ access_token, token_type: 'bearer' });
|
||||
}
|
||||
|
||||
function mockRequest(url: string, body?: any, headers?: any) {
|
||||
http.get(url).subscribe(emptyFn, emptyFn, emptyFn);
|
||||
const testRequest = httpMock.expectOne(url);
|
||||
testRequest.flush(body ?? {}, headers ?? {});
|
||||
|
||||
return testRequest;
|
||||
}
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('should append token when url does not has http scheme', () => {
|
||||
init('', 'token');
|
||||
|
||||
const headers = mockRequest('/me', user).request.headers;
|
||||
|
||||
expect(headers.get('Authorization')).toEqual('Bearer token');
|
||||
});
|
||||
|
||||
it('should append token when url does not has http and base url not empty', () => {
|
||||
init(baseUrl, 'token');
|
||||
|
||||
const headers = mockRequest('/me', user).request.headers;
|
||||
|
||||
expect(headers.get('Authorization')).toEqual('Bearer token');
|
||||
});
|
||||
|
||||
it('should append token when url include base url', () => {
|
||||
init(baseUrl, 'token');
|
||||
|
||||
const headers = mockRequest(`${baseUrl}/me`, user).request.headers;
|
||||
|
||||
expect(headers.get('Authorization')).toEqual('Bearer token');
|
||||
});
|
||||
|
||||
it('should not append token when url not include baseUrl', () => {
|
||||
init(baseUrl, 'token');
|
||||
|
||||
const headers = mockRequest('https://api.github.com', { success: true }).request.headers;
|
||||
|
||||
expect(headers.has('Authorization')).toBeFalse();
|
||||
});
|
||||
|
||||
it('should not append token when base url is empty and url is not same site', () => {
|
||||
init('', 'token');
|
||||
|
||||
const headers = mockRequest('https://api.github.com', { success: true }).request.headers;
|
||||
|
||||
expect(headers.has('Authorization')).toBeFalse();
|
||||
});
|
||||
|
||||
it('should clear token when response status is unauthorized', () => {
|
||||
init('', 'token');
|
||||
spyOn(tokenService, 'clear');
|
||||
|
||||
mockRequest('/me', {}, { status: STATUS.UNAUTHORIZED, statusText: 'Unauthorized' });
|
||||
|
||||
expect(tokenService.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should navigate /auth/login when api url is /auth/logout and token is valid', () => {
|
||||
init('', 'token');
|
||||
const navigateByUrl = spyOn(router, 'navigateByUrl');
|
||||
navigateByUrl.and.returnValue(Promise.resolve(true));
|
||||
|
||||
mockRequest('/auth/logout');
|
||||
|
||||
expect(navigateByUrl).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
|
||||
it('should navigate /auth/login when api url is /auth/logout and token is invalid', () => {
|
||||
init('', '');
|
||||
const navigateByUrl = spyOn(router, 'navigateByUrl');
|
||||
navigateByUrl.and.returnValue(Promise.resolve(true));
|
||||
|
||||
mockRequest('/auth/logout');
|
||||
|
||||
expect(navigateByUrl).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
});
|
||||
71
front/app/src/app/core/interceptors/token-interceptor.ts
Normal file
71
front/app/src/app/core/interceptors/token-interceptor.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Inject, Injectable, Optional } from '@angular/core';
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError, tap } from 'rxjs/operators';
|
||||
import { TokenService } from '@core/authentication';
|
||||
import { BASE_URL } from './base-url-interceptor';
|
||||
|
||||
@Injectable()
|
||||
export class TokenInterceptor implements HttpInterceptor {
|
||||
private hasHttpScheme = (url: string) => new RegExp('^http(s)?://', 'i').test(url);
|
||||
|
||||
constructor(
|
||||
private tokenService: TokenService,
|
||||
private router: Router,
|
||||
@Optional() @Inject(BASE_URL) private baseUrl?: string
|
||||
) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
const handler = () => {
|
||||
if (request.url.includes('/auth/logout')) {
|
||||
this.router.navigateByUrl('/auth/login');
|
||||
}
|
||||
|
||||
if (this.router.url.includes('/auth/login')) {
|
||||
this.router.navigateByUrl('/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
if (this.tokenService.valid() && this.shouldAppendToken(request.url)) {
|
||||
return next
|
||||
.handle(
|
||||
request.clone({
|
||||
headers: request.headers.append('Authorization', this.tokenService.getBearerToken()),
|
||||
withCredentials: true,
|
||||
})
|
||||
)
|
||||
.pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401) {
|
||||
this.tokenService.clear();
|
||||
}
|
||||
return throwError(error);
|
||||
}),
|
||||
tap(() => handler())
|
||||
);
|
||||
}
|
||||
|
||||
return next.handle(request).pipe(tap(() => handler()));
|
||||
}
|
||||
|
||||
private shouldAppendToken(url: string) {
|
||||
return !this.hasHttpScheme(url) || this.includeBaseUrl(url);
|
||||
}
|
||||
|
||||
private includeBaseUrl(url: string) {
|
||||
if (!this.baseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseUrl = this.baseUrl.replace(/\/$/, '');
|
||||
|
||||
return new RegExp(`^${baseUrl}`, 'i').test(url);
|
||||
}
|
||||
}
|
||||
7
front/app/src/app/core/module-import-guard.ts
Normal file
7
front/app/src/app/core/module-import-guard.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function throwIfAlreadyLoaded(parentModule: any, moduleName: string) {
|
||||
if (parentModule) {
|
||||
throw new Error(
|
||||
`${moduleName} has already been loaded. Import Core modules in the AppModule only.`
|
||||
);
|
||||
}
|
||||
}
|
||||
23
front/app/src/app/core/settings.ts
Normal file
23
front/app/src/app/core/settings.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface AppSettings {
|
||||
navPos: 'side' | 'top';
|
||||
theme: 'light' | 'dark' | 'auto';
|
||||
dir: 'ltr' | 'rtl';
|
||||
showHeader: boolean;
|
||||
headerPos: 'fixed' | 'static' | 'above';
|
||||
showUserPanel: boolean;
|
||||
sidenavOpened: boolean;
|
||||
sidenavCollapsed: boolean;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export const defaults: AppSettings = {
|
||||
navPos: 'side',
|
||||
theme: 'auto',
|
||||
dir: 'ltr',
|
||||
showHeader: true,
|
||||
headerPos: 'fixed',
|
||||
showUserPanel: true,
|
||||
sidenavOpened: true,
|
||||
sidenavCollapsed: false,
|
||||
language: 'en-US',
|
||||
};
|
||||
34
front/app/src/app/fake-login.service.ts
Normal file
34
front/app/src/app/fake-login.service.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
import { admin, LoginService, Menu } from '@core';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* You should delete this file in the real APP.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FakeLoginService extends LoginService {
|
||||
private token = { access_token: 'MW56YjMyOUAxNjMuY29tWm9uZ2Jpbg==', token_type: 'bearer' };
|
||||
|
||||
login() {
|
||||
return of(this.token);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
return of(this.token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return of({});
|
||||
}
|
||||
|
||||
me() {
|
||||
return of(admin);
|
||||
}
|
||||
|
||||
menu() {
|
||||
return this.http
|
||||
.get<{ menu: Menu[] }>('assets/data/menu.json?_t=' + Date.now())
|
||||
.pipe(map(res => res.menu));
|
||||
}
|
||||
}
|
||||
53
front/app/src/app/formly-config.module.ts
Normal file
53
front/app/src/app/formly-config.module.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NgModule, ModuleWithProviders, Provider } from '@angular/core';
|
||||
import { SharedModule } from './shared/shared.module';
|
||||
|
||||
import { FormlyModule } from '@ngx-formly/core';
|
||||
import { FormlyFieldComboboxComponent } from './formly-templates';
|
||||
import { FormlyWrapperCardComponent, FormlyWrapperDivComponent } from './formly-wrappers';
|
||||
import { FormlyValidations } from './formly-validations';
|
||||
|
||||
/**
|
||||
* Formly global configuration
|
||||
*/
|
||||
const formlyModuleProviders = FormlyModule.forRoot({
|
||||
types: [
|
||||
{
|
||||
name: 'combobox',
|
||||
component: FormlyFieldComboboxComponent,
|
||||
wrappers: ['form-field'],
|
||||
},
|
||||
],
|
||||
wrappers: [
|
||||
{
|
||||
name: 'card',
|
||||
component: FormlyWrapperCardComponent,
|
||||
},
|
||||
{
|
||||
name: 'div',
|
||||
component: FormlyWrapperDivComponent,
|
||||
},
|
||||
],
|
||||
validationMessages: [],
|
||||
}).providers as Provider[];
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule],
|
||||
declarations: [
|
||||
FormlyFieldComboboxComponent,
|
||||
FormlyWrapperCardComponent,
|
||||
FormlyWrapperDivComponent,
|
||||
],
|
||||
providers: [FormlyValidations],
|
||||
})
|
||||
export class FormlyConfigModule {
|
||||
constructor(formlyValidations: FormlyValidations) {
|
||||
formlyValidations.init();
|
||||
}
|
||||
|
||||
static forRoot(): ModuleWithProviders<FormlyConfigModule> {
|
||||
return {
|
||||
ngModule: FormlyConfigModule,
|
||||
providers: [formlyModuleProviders],
|
||||
};
|
||||
}
|
||||
}
|
||||
45
front/app/src/app/formly-templates.ts
Normal file
45
front/app/src/app/formly-templates.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ViewChild, ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { FieldType } from '@ngx-formly/material/form-field';
|
||||
import { MtxSelect } from '@ng-matero/extensions/select';
|
||||
import { FieldTypeConfig } from '@ngx-formly/core';
|
||||
|
||||
/**
|
||||
* This is just an example.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'formly-field-combobox',
|
||||
template: `<mtx-select
|
||||
#select
|
||||
[formControl]="formControl"
|
||||
[items]="props.options | toObservable | async"
|
||||
[bindLabel]="bindLabel"
|
||||
[bindValue]="bindValue!"
|
||||
[multiple]="props.multiple"
|
||||
[placeholder]="props.placeholder!"
|
||||
[required]="props.required!"
|
||||
[closeOnSelect]="!props.multiple"
|
||||
[compareWith]="props.compareWith"
|
||||
>
|
||||
</mtx-select>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormlyFieldComboboxComponent extends FieldType<FieldTypeConfig> {
|
||||
@ViewChild('select', { static: true }) select!: MtxSelect;
|
||||
|
||||
get bindLabel() {
|
||||
return typeof this.props.labelProp === 'string' ? this.props.labelProp : '';
|
||||
}
|
||||
|
||||
get bindValue() {
|
||||
return typeof this.props.valueProp === 'string' ? this.props.valueProp : undefined;
|
||||
}
|
||||
|
||||
// The original `onContainerClick` has been covered up in FieldType, so we should redefine it.
|
||||
onContainerClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (/mat-form-field|mtx-select/g.test(target.parentElement?.classList[0] || '')) {
|
||||
this.select.focus();
|
||||
this.select.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
53
front/app/src/app/formly-validations.ts
Normal file
53
front/app/src/app/formly-validations.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { FormlyFieldConfig, FormlyConfig } from '@ngx-formly/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
@Injectable()
|
||||
export class FormlyValidations {
|
||||
constructor(private translate: TranslateService, private formlyConfig: FormlyConfig) {}
|
||||
|
||||
init(): void {
|
||||
// message without params
|
||||
this.formlyConfig.addValidatorMessage('required', (_err, _field) =>
|
||||
this.translate.stream('validations.required')
|
||||
);
|
||||
|
||||
// message with params
|
||||
this.formlyConfig.addValidatorMessage('minLength', (err, field) =>
|
||||
this.minLengthValidationMessage(err, field, this.translate)
|
||||
);
|
||||
this.formlyConfig.addValidatorMessage('maxLength', (err, field) =>
|
||||
this.maxLengthValidationMessage(err, field, this.translate)
|
||||
);
|
||||
this.formlyConfig.addValidatorMessage('min', (err, field) =>
|
||||
this.minValidationMessage(err, field, this.translate)
|
||||
);
|
||||
this.formlyConfig.addValidatorMessage('max', (err, field) =>
|
||||
this.maxValidationMessage(err, field, this.translate)
|
||||
);
|
||||
}
|
||||
|
||||
private minLengthValidationMessage(
|
||||
err: any,
|
||||
field: FormlyFieldConfig,
|
||||
translate: TranslateService
|
||||
) {
|
||||
return translate.stream('validations.minlength', { number: field.props?.minLength });
|
||||
}
|
||||
|
||||
private maxLengthValidationMessage(
|
||||
err: any,
|
||||
field: FormlyFieldConfig,
|
||||
translate: TranslateService
|
||||
) {
|
||||
return translate.stream('validations.maxlength', { number: field.props?.maxLength });
|
||||
}
|
||||
|
||||
private minValidationMessage(err: any, field: FormlyFieldConfig, translate: TranslateService) {
|
||||
return translate.stream('validations.min', { number: field.props?.min });
|
||||
}
|
||||
|
||||
private maxValidationMessage(err: any, field: FormlyFieldConfig, translate: TranslateService) {
|
||||
return translate.stream('validations.max', { number: field.props?.max });
|
||||
}
|
||||
}
|
||||
29
front/app/src/app/formly-wrappers.ts
Normal file
29
front/app/src/app/formly-wrappers.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FieldWrapper } from '@ngx-formly/core';
|
||||
|
||||
/**
|
||||
* This is just an example.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'formly-wrapper-card',
|
||||
template: `
|
||||
<div class="card">
|
||||
<h3 class="card-header">Its time to party</h3>
|
||||
<h3 class="card-header">{{ props.label }}</h3>
|
||||
<div class="card-body">
|
||||
<ng-container #fieldComponent></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class FormlyWrapperCardComponent extends FieldWrapper {}
|
||||
|
||||
@Component({
|
||||
selector: 'formly-wrapper-div',
|
||||
template: `
|
||||
<div>
|
||||
<ng-container #fieldComponent></ng-container>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class FormlyWrapperDivComponent extends FieldWrapper {}
|
||||
66
front/app/src/app/material-extensions.module.ts
Normal file
66
front/app/src/app/material-extensions.module.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { MtxAlertModule } from '@ng-matero/extensions/alert';
|
||||
import { MtxButtonModule } from '@ng-matero/extensions/button';
|
||||
import { MtxCheckboxGroupModule } from '@ng-matero/extensions/checkbox-group';
|
||||
import { MtxColorpickerModule } from '@ng-matero/extensions/colorpicker';
|
||||
import { MtxDatetimepickerModule } from '@ng-matero/extensions/datetimepicker';
|
||||
import { MtxDialogModule } from '@ng-matero/extensions/dialog';
|
||||
import { MtxDrawerModule } from '@ng-matero/extensions/drawer';
|
||||
import { MtxGridModule } from '@ng-matero/extensions/grid';
|
||||
import { MtxLoaderModule } from '@ng-matero/extensions/loader';
|
||||
import { MtxPopoverModule } from '@ng-matero/extensions/popover';
|
||||
import { MtxProgressModule } from '@ng-matero/extensions/progress';
|
||||
import { MtxSelectModule } from '@ng-matero/extensions/select';
|
||||
import { MtxSliderModule } from '@ng-matero/extensions/slider';
|
||||
import { MtxSplitModule } from '@ng-matero/extensions/split';
|
||||
import { MtxTooltipModule } from '@ng-matero/extensions/tooltip';
|
||||
import { MTX_DATETIME_FORMATS } from '@ng-matero/extensions/core';
|
||||
import { MtxMomentDatetimeModule } from '@ng-matero/extensions-moment-adapter';
|
||||
|
||||
@NgModule({
|
||||
exports: [
|
||||
MtxAlertModule,
|
||||
MtxButtonModule,
|
||||
MtxCheckboxGroupModule,
|
||||
MtxColorpickerModule,
|
||||
MtxDatetimepickerModule,
|
||||
MtxDialogModule,
|
||||
MtxDrawerModule,
|
||||
MtxGridModule,
|
||||
MtxLoaderModule,
|
||||
MtxPopoverModule,
|
||||
MtxProgressModule,
|
||||
MtxSelectModule,
|
||||
MtxSliderModule,
|
||||
MtxSplitModule,
|
||||
MtxTooltipModule,
|
||||
MtxMomentDatetimeModule, // <= You can import the other adapter you need (luxon, date-fns)
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MTX_DATETIME_FORMATS,
|
||||
useValue: {
|
||||
parse: {
|
||||
dateInput: 'YYYY-MM-DD',
|
||||
yearInput: 'YYYY',
|
||||
monthInput: 'MMMM',
|
||||
datetimeInput: 'YYYY-MM-DD HH:mm',
|
||||
timeInput: 'HH:mm',
|
||||
},
|
||||
display: {
|
||||
dateInput: 'YYYY-MM-DD',
|
||||
yearInput: 'YYYY',
|
||||
monthInput: 'MMMM',
|
||||
datetimeInput: 'YYYY-MM-DD HH:mm',
|
||||
timeInput: 'HH:mm',
|
||||
monthYearLabel: 'YYYY MMMM',
|
||||
dateA11yLabel: 'LL',
|
||||
monthYearA11yLabel: 'MMMM YYYY',
|
||||
popupHeaderDateLabel: 'MMM DD, ddd',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
export class MaterialExtensionsModule {}
|
||||
117
front/app/src/app/material.module.ts
Normal file
117
front/app/src/app/material.module.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatRippleModule, MAT_DATE_FORMATS, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import {
|
||||
MatDialogConfig,
|
||||
MatDialogModule,
|
||||
MAT_DIALOG_DEFAULT_OPTIONS,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatGridListModule } from '@angular/material/grid-list';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorIntl, MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatSliderModule } from '@angular/material/slider';
|
||||
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatStepperModule } from '@angular/material/stepper';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatTreeModule } from '@angular/material/tree';
|
||||
import { MatMomentDateModule } from '@angular/material-moment-adapter';
|
||||
|
||||
import { PaginatorI18nService } from '@shared/services/paginator-i18n.service';
|
||||
|
||||
@NgModule({
|
||||
exports: [
|
||||
MatAutocompleteModule,
|
||||
MatBadgeModule,
|
||||
MatBottomSheetModule,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
MatCardModule,
|
||||
MatCheckboxModule,
|
||||
MatChipsModule,
|
||||
MatStepperModule,
|
||||
MatDatepickerModule,
|
||||
MatMomentDateModule,
|
||||
MatDialogModule,
|
||||
MatDividerModule,
|
||||
MatExpansionModule,
|
||||
MatFormFieldModule,
|
||||
MatGridListModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatListModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatProgressBarModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatRadioModule,
|
||||
MatRippleModule,
|
||||
MatSelectModule,
|
||||
MatSidenavModule,
|
||||
MatSliderModule,
|
||||
MatSlideToggleModule,
|
||||
MatSnackBarModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
MatTabsModule,
|
||||
MatToolbarModule,
|
||||
MatTooltipModule,
|
||||
MatTreeModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MatPaginatorIntl,
|
||||
deps: [PaginatorI18nService],
|
||||
useFactory: (paginatorI18nSrv: PaginatorI18nService) => paginatorI18nSrv.getPaginatorIntl(),
|
||||
},
|
||||
{
|
||||
provide: MAT_DIALOG_DEFAULT_OPTIONS,
|
||||
useValue: {
|
||||
...new MatDialogConfig(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MAT_DATE_LOCALE,
|
||||
useFactory: () => navigator.language, // <= This will be overrided by runtime setting
|
||||
},
|
||||
{
|
||||
provide: MAT_DATE_FORMATS,
|
||||
useValue: {
|
||||
parse: {
|
||||
dateInput: 'YYYY-MM-DD',
|
||||
},
|
||||
display: {
|
||||
dateInput: 'YYYY-MM-DD',
|
||||
monthYearLabel: 'YYYY MMM',
|
||||
dateA11yLabel: 'LL',
|
||||
monthYearA11yLabel: 'YYYY MMM',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
export class MaterialModule {}
|
||||
@@ -0,0 +1 @@
|
||||
<page-header></page-header>
|
||||
13
front/app/src/app/routes/dashboard/dashboard.component.ts
Normal file
13
front/app/src/app/routes/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
templateUrl: './dashboard.component.html',
|
||||
styleUrls: ['./dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DashboardComponent implements OnInit {
|
||||
constructor(private cdr: ChangeDetectorRef) {}
|
||||
|
||||
ngOnInit() {}
|
||||
}
|
||||
48
front/app/src/app/routes/routes-routing.module.ts
Normal file
48
front/app/src/app/routes/routes-routing.module.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { environment } from '@env/environment';
|
||||
|
||||
import { AdminLayoutComponent } from '../theme/admin-layout/admin-layout.component';
|
||||
import { AuthLayoutComponent } from '../theme/auth-layout/auth-layout.component';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { LoginComponent } from './sessions/login/login.component';
|
||||
import { RegisterComponent } from './sessions/register/register.component';
|
||||
import { Error403Component } from './sessions/403.component';
|
||||
import { Error404Component } from './sessions/404.component';
|
||||
import { Error500Component } from './sessions/500.component';
|
||||
import { AuthGuard } from '@core';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AdminLayoutComponent,
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
children: [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', component: DashboardComponent },
|
||||
{ path: '403', component: Error403Component },
|
||||
{ path: '404', component: Error404Component },
|
||||
{ path: '500', component: Error500Component },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
component: AuthLayoutComponent,
|
||||
children: [
|
||||
{ path: 'login', component: LoginComponent },
|
||||
{ path: 'register', component: RegisterComponent },
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: 'dashboard' },
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
useHash: environment.useHash,
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class RoutesRoutingModule {}
|
||||
26
front/app/src/app/routes/routes.module.ts
Normal file
26
front/app/src/app/routes/routes.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { SharedModule } from '@shared/shared.module';
|
||||
import { RoutesRoutingModule } from './routes-routing.module';
|
||||
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { LoginComponent } from './sessions/login/login.component';
|
||||
import { RegisterComponent } from './sessions/register/register.component';
|
||||
import { Error403Component } from './sessions/403.component';
|
||||
import { Error404Component } from './sessions/404.component';
|
||||
import { Error500Component } from './sessions/500.component';
|
||||
|
||||
const COMPONENTS: any[] = [
|
||||
DashboardComponent,
|
||||
LoginComponent,
|
||||
RegisterComponent,
|
||||
Error403Component,
|
||||
Error404Component,
|
||||
Error500Component,
|
||||
];
|
||||
const COMPONENTS_DYNAMIC: any[] = [];
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, RoutesRoutingModule],
|
||||
declarations: [...COMPONENTS, ...COMPONENTS_DYNAMIC],
|
||||
})
|
||||
export class RoutesModule {}
|
||||
13
front/app/src/app/routes/sessions/403.component.ts
Normal file
13
front/app/src/app/routes/sessions/403.component.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-error-403',
|
||||
template: `
|
||||
<error-code
|
||||
code="403"
|
||||
[title]="'Permission denied!'"
|
||||
[message]="'You do not have permission to access the requested data.'"
|
||||
></error-code>
|
||||
`,
|
||||
})
|
||||
export class Error403Component {}
|
||||
13
front/app/src/app/routes/sessions/404.component.ts
Normal file
13
front/app/src/app/routes/sessions/404.component.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-error-404',
|
||||
template: `
|
||||
<error-code
|
||||
code="404"
|
||||
[title]="'Page not found!'"
|
||||
[message]="'This is not the web page you are looking for.'"
|
||||
></error-code>
|
||||
`,
|
||||
})
|
||||
export class Error404Component {}
|
||||
14
front/app/src/app/routes/sessions/500.component.ts
Normal file
14
front/app/src/app/routes/sessions/500.component.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-error-500',
|
||||
template: `
|
||||
<error-code
|
||||
code="500"
|
||||
[title]="'Server went wrong!'"
|
||||
[message]="'Just kidding, looks like we have an internal issue, please try refreshing.'"
|
||||
>
|
||||
</error-code>
|
||||
`,
|
||||
})
|
||||
export class Error500Component {}
|
||||
45
front/app/src/app/routes/sessions/login/login.component.html
Normal file
45
front/app/src/app/routes/sessions/login/login.component.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<div class="d-flex w-full h-full">
|
||||
<mat-card class="m-auto" style="max-width: 380px;">
|
||||
<mat-card-header class="m-b-24">
|
||||
<mat-card-title>{{'login.title' | translate}}!</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form class="form-field-full" [formGroup]="loginForm">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{'login.username' | translate}}: ng-matero</mat-label>
|
||||
<input matInput placeholder="ng-matero" formControlName="username" required>
|
||||
<mat-error *ngIf="username.invalid">
|
||||
<span *ngIf="username.errors?.required">{{'login.please_enter' | translate}}
|
||||
<strong>ng-matero</strong>
|
||||
</span>
|
||||
<span *ngIf="username.errors?.remote">{{ username.errors?.remote }}</span>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{'login.password' | translate}}: ng-matero</mat-label>
|
||||
<input matInput placeholder="ng-matero" type="password"
|
||||
formControlName="password" required>
|
||||
<mat-error *ngIf="password.invalid">
|
||||
<span *ngIf="password.errors?.required">{{'login.please_enter' | translate}}
|
||||
<strong>ng-matero</strong>
|
||||
</span>
|
||||
<span *ngIf="password.errors?.remote">{{ password.errors?.remote }}</span>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox formControlName="rememberMe">{{'login.remember_me' | translate}}
|
||||
</mat-checkbox>
|
||||
|
||||
<button class="w-full m-y-16" mat-raised-button color="primary"
|
||||
[disabled]="!!loginForm.invalid" [loading]="isSubmitting"
|
||||
(click)="login()">{{'login.login' | translate}}</button>
|
||||
|
||||
<div>{{'login.have_no_account' | translate}}?
|
||||
<a routerLink="/auth/register">{{'login.create_one' | translate}}</a>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
58
front/app/src/app/routes/sessions/login/login.component.ts
Normal file
58
front/app/src/app/routes/sessions/login/login.component.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { AuthService } from '@core/authentication';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
templateUrl: './login.component.html',
|
||||
styleUrls: ['./login.component.scss'],
|
||||
})
|
||||
export class LoginComponent {
|
||||
isSubmitting = false;
|
||||
|
||||
loginForm = this.fb.nonNullable.group({
|
||||
username: ['ng-matero', [Validators.required]],
|
||||
password: ['ng-matero', [Validators.required]],
|
||||
rememberMe: [false],
|
||||
});
|
||||
|
||||
constructor(private fb: FormBuilder, private router: Router, private auth: AuthService) {}
|
||||
|
||||
get username() {
|
||||
return this.loginForm.get('username')!;
|
||||
}
|
||||
|
||||
get password() {
|
||||
return this.loginForm.get('password')!;
|
||||
}
|
||||
|
||||
get rememberMe() {
|
||||
return this.loginForm.get('rememberMe')!;
|
||||
}
|
||||
|
||||
login() {
|
||||
this.isSubmitting = true;
|
||||
|
||||
this.auth
|
||||
.login(this.username.value, this.password.value, this.rememberMe.value)
|
||||
.pipe(filter(authenticated => authenticated))
|
||||
.subscribe(
|
||||
() => this.router.navigateByUrl('/'),
|
||||
(errorRes: HttpErrorResponse) => {
|
||||
if (errorRes.status === 422) {
|
||||
const form = this.loginForm;
|
||||
const errors = errorRes.error.errors;
|
||||
Object.keys(errors).forEach(key => {
|
||||
form.get(key === 'email' ? 'username' : key)?.setErrors({
|
||||
remote: errors[key][0],
|
||||
});
|
||||
});
|
||||
}
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<div class="d-flex w-full h-full">
|
||||
<mat-card class="m-auto" style="max-width: 380px;">
|
||||
<mat-card-header class="m-b-24">
|
||||
<mat-card-title>
|
||||
{{'register.welcome' | translate}}, <br />
|
||||
{{'register.title' | translate}}
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form class="form-field-full" [formGroup]="registerForm">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{'login.username' | translate}}</mat-label>
|
||||
<input matInput formControlName="username" required>
|
||||
<mat-error *ngIf="registerForm.get('username')?.invalid">
|
||||
<span>{{'validations.required' | translate}}</span>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{'login.password' | translate}}</mat-label>
|
||||
<input matInput type="password" formControlName="password" required>
|
||||
<mat-error *ngIf="registerForm.get('password')?.invalid">
|
||||
<span>{{'validations.required' | translate}}</span>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{'register.confirm_password' | translate}}</mat-label>
|
||||
<input matInput type="password" formControlName="confirmPassword" required>
|
||||
<mat-error *ngIf="registerForm.get('confirmPassword')?.hasError('required')">
|
||||
<span>{{'validations.required' | translate}}</span>
|
||||
</mat-error>
|
||||
<mat-error *ngIf="registerForm.get('confirmPassword')?.hasError('mismatch')"
|
||||
translate [translateParams]="{value: 'login.password' | translate}">
|
||||
<span>{{'validations.inconsistent'}}</span>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox>{{'register.agree' | translate}}</mat-checkbox>
|
||||
|
||||
<button class="w-full m-y-16" mat-raised-button color="primary">
|
||||
{{'register.register' | translate}}
|
||||
</button>
|
||||
|
||||
<div>{{'register.have_an_account' | translate}}?
|
||||
<a routerLink="/auth/login">{{'login.login' | translate}}</a>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FormBuilder, Validators, AbstractControl } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
templateUrl: './register.component.html',
|
||||
styleUrls: ['./register.component.scss'],
|
||||
})
|
||||
export class RegisterComponent {
|
||||
registerForm = this.fb.nonNullable.group(
|
||||
{
|
||||
username: ['', [Validators.required]],
|
||||
password: ['', [Validators.required]],
|
||||
confirmPassword: ['', [Validators.required]],
|
||||
},
|
||||
{
|
||||
validators: [this.matchValidator('password', 'confirmPassword')],
|
||||
}
|
||||
);
|
||||
|
||||
constructor(private fb: FormBuilder) {}
|
||||
|
||||
matchValidator(source: string, target: string) {
|
||||
return (control: AbstractControl) => {
|
||||
const sourceControl = control.get(source)!;
|
||||
const targetControl = control.get(target)!;
|
||||
if (targetControl.errors && !targetControl.errors.mismatch) {
|
||||
return null;
|
||||
}
|
||||
if (sourceControl.value !== targetControl.value) {
|
||||
targetControl.setErrors({ mismatch: true });
|
||||
return { mismatch: true };
|
||||
} else {
|
||||
targetControl.setErrors(null);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="matero-breadcrumb">
|
||||
<li class="matero-breadcrumb-item"
|
||||
*ngFor="let navLink of nav; trackBy: trackByNavlink; first as isFirst;">
|
||||
<a href="#" class="link" *ngIf="isFirst">{{navLink}}</a>
|
||||
<ng-container *ngIf="!isFirst">
|
||||
<mat-icon class="chevron">chevron_right</mat-icon>
|
||||
<span>{{navLink | translate}}</span>
|
||||
</ng-container>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -0,0 +1,33 @@
|
||||
.matero-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0;
|
||||
font-size: .875rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.matero-breadcrumb-item {
|
||||
line-height: 18px;
|
||||
text-transform: capitalize;
|
||||
|
||||
> * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
> a.link {
|
||||
color: currentColor;
|
||||
|
||||
&:hover {
|
||||
color: currentColor;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
> .chevron {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, OnInit, ViewEncapsulation, Input } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { MenuService } from '@core/bootstrap/menu.service';
|
||||
|
||||
@Component({
|
||||
selector: 'breadcrumb',
|
||||
templateUrl: './breadcrumb.component.html',
|
||||
styleUrls: ['./breadcrumb.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class BreadcrumbComponent implements OnInit {
|
||||
@Input() nav: string[] = [];
|
||||
|
||||
constructor(private router: Router, private menu: MenuService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.nav = Array.isArray(this.nav) ? this.nav : [];
|
||||
|
||||
if (this.nav.length === 0) {
|
||||
this.genBreadcrumb();
|
||||
}
|
||||
}
|
||||
|
||||
trackByNavlink(index: number, navLink: string): string {
|
||||
return navLink;
|
||||
}
|
||||
|
||||
genBreadcrumb() {
|
||||
const routes = this.router.url.slice(1).split('/');
|
||||
this.nav = this.menu.getLevel(routes);
|
||||
this.nav.unshift('home');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@use 'sass:map';
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
@mixin theme($theme) {
|
||||
$background: map.get($theme, background);
|
||||
$foreground: map.get($theme, foreground);
|
||||
|
||||
.matero-error-code {
|
||||
color: mat.get-color-from-palette($foreground, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Long Shadow
|
||||
//
|
||||
// https://codepen.io/c_fitzmaurice/pen/ZYJeRY
|
||||
|
||||
@use 'sass:color';
|
||||
@use 'sass:list';
|
||||
@use 'sass:meta';
|
||||
@use 'sass:map';
|
||||
@use 'sass:math';
|
||||
|
||||
@function long-shadow($direction, $length, $color, $fade: false, $shadow-count: 100) {
|
||||
$shadows: ();
|
||||
$conversion-map: (
|
||||
to top: 180deg,
|
||||
to top right: 135deg,
|
||||
to right top: 135deg,
|
||||
to right: 90deg,
|
||||
to bottom right: 45deg,
|
||||
to right bottom: 45deg,
|
||||
to bottom: 0deg,
|
||||
to bottom left: 315deg,
|
||||
to left bottom: 315deg,
|
||||
to left: 270deg,
|
||||
to left top: 225deg,
|
||||
to top left: 225deg
|
||||
);
|
||||
|
||||
@if map-has-key($conversion-map, $direction) {
|
||||
$direction: map.get($conversion-map, $direction);
|
||||
}
|
||||
|
||||
@for $i from 1 through $shadow-count {
|
||||
$current-step: math.div($i * $length, $shadow-count);
|
||||
$current-color: if(
|
||||
not $fade,
|
||||
$color,
|
||||
if(
|
||||
meta.type-of($fade) == 'color',
|
||||
color.mix($fade, $color, math.div($i, $shadow-count) * 100%),
|
||||
color.rgba($color, 1 - math.div($i, $shadow-count))
|
||||
)
|
||||
);
|
||||
|
||||
$shadows: list.append(
|
||||
$shadows,
|
||||
(math.sin(0deg + $direction) * $current-step)
|
||||
(math.cos(0deg + $direction) * $current-step)
|
||||
0
|
||||
$current-color,
|
||||
'comma'
|
||||
);
|
||||
}
|
||||
|
||||
@return $shadows;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="matero-error-wrap">
|
||||
<div class="matero-error-code">{{code}}</div>
|
||||
<div class="matero-error-title" *ngIf="title">{{title}}</div>
|
||||
<div class="matero-error-message" *ngIf="message">{{message}}</div>
|
||||
<div><a mat-raised-button color="primary" routerLink="/">Back to Home</a></div>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
@use 'long-shadow';
|
||||
|
||||
.matero-error-wrap {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.matero-error-code {
|
||||
padding: 20px 0;
|
||||
font-size: 150px;
|
||||
text-shadow:
|
||||
long-shadow.long-shadow(
|
||||
$direction: 45deg,
|
||||
$length: 60px,
|
||||
$color: rgba(0, 0, 0, .03),
|
||||
$fade: rgba(0, 0, 0, .0015),
|
||||
$shadow-count: 20
|
||||
);
|
||||
}
|
||||
|
||||
.matero-error-title {
|
||||
margin: 0 0 16px;
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.matero-error-message {
|
||||
margin: 0 0 16px;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 28px;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component, ViewEncapsulation, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'error-code',
|
||||
templateUrl: './error-code.component.html',
|
||||
styleUrls: ['./error-code.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class ErrorCodeComponent {
|
||||
@Input() code = '';
|
||||
@Input() title = '';
|
||||
@Input() message = '';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="matero-page-header-inner">
|
||||
<h1 class="matero-page-title">{{title | translate}} <small>{{subtitle}}</small></h1>
|
||||
<breadcrumb *ngIf="!hideBreadcrumb" [nav]="nav"></breadcrumb>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
.matero-page-header {
|
||||
display: block;
|
||||
margin: -16px -16px 16px;
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
background-color: #3f51b5;
|
||||
|
||||
.matero-breadcrumb {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.matero-page-title {
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Component, OnInit, ViewEncapsulation, Input, HostBinding } from '@angular/core';
|
||||
import { MenuService } from '@core/bootstrap/menu.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
|
||||
@Component({
|
||||
selector: 'page-header',
|
||||
templateUrl: './page-header.component.html',
|
||||
styleUrls: ['./page-header.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class PageHeaderComponent implements OnInit {
|
||||
@HostBinding('class') class = 'matero-page-header';
|
||||
|
||||
@Input() title = '';
|
||||
@Input() subtitle = '';
|
||||
@Input() nav: string[] = [];
|
||||
@Input()
|
||||
get hideBreadcrumb() {
|
||||
return this._hideBreadCrumb;
|
||||
}
|
||||
set hideBreadcrumb(value: boolean) {
|
||||
this._hideBreadCrumb = coerceBooleanProperty(value);
|
||||
}
|
||||
private _hideBreadCrumb = false;
|
||||
|
||||
constructor(private router: Router, private menu: MenuService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.nav = Array.isArray(this.nav) ? this.nav : [];
|
||||
|
||||
if (this.nav.length === 0) {
|
||||
this.genBreadcrumb();
|
||||
}
|
||||
|
||||
this.title = this.title || this.nav[this.nav.length - 1];
|
||||
}
|
||||
|
||||
genBreadcrumb() {
|
||||
const routes = this.router.url.slice(1).split('/');
|
||||
this.nav = this.menu.getLevel(routes);
|
||||
this.nav.unshift('home');
|
||||
}
|
||||
|
||||
static ngAcceptInputType_hideBreadcrumb: BooleanInput;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NgControl } from '@angular/forms';
|
||||
import { Directive, Input, SkipSelf, Optional } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: '[disableControl]',
|
||||
})
|
||||
export class DisableControlDirective {
|
||||
@Input() set disableControl(condition: boolean) {
|
||||
const action = condition ? 'disable' : 'enable';
|
||||
this.ngControl.control?.[action]();
|
||||
}
|
||||
|
||||
constructor(@Optional() @SkipSelf() private ngControl: NgControl) {}
|
||||
}
|
||||
16
front/app/src/app/shared/in-mem/in-mem-data.service.spec.ts
Normal file
16
front/app/src/app/shared/in-mem/in-mem-data.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { InMemDataService } from './in-mem-data.service';
|
||||
|
||||
describe('InMemDataService', () => {
|
||||
let service: InMemDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(InMemDataService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
209
front/app/src/app/shared/in-mem/in-mem-data.service.ts
Normal file
209
front/app/src/app/shared/in-mem/in-mem-data.service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpRequest } from '@angular/common/http';
|
||||
import { InMemoryDbService, RequestInfo, STATUS } from 'angular-in-memory-web-api';
|
||||
import { from, Observable } from 'rxjs';
|
||||
import { ajax } from 'rxjs/ajax';
|
||||
import { find, map, switchMap } from 'rxjs/operators';
|
||||
import { environment } from '@env/environment';
|
||||
import { base64, currentTimestamp, filterObject, User } from '@core/authentication';
|
||||
|
||||
class JWT {
|
||||
generate(user: User) {
|
||||
const expiresIn = 3600;
|
||||
const refreshTokenExpiresIn = 86400;
|
||||
|
||||
return filterObject({
|
||||
access_token: this.createToken(user, expiresIn),
|
||||
token_type: 'bearer',
|
||||
expires_in: user.refresh_token ? expiresIn : undefined,
|
||||
refresh_token: user.refresh_token ? this.createToken(user, refreshTokenExpiresIn) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
getUser(req: HttpRequest<any>) {
|
||||
let token = '';
|
||||
|
||||
if (req.body?.refresh_token) {
|
||||
token = req.body.refresh_token;
|
||||
} else if (req.headers.has('Authorization')) {
|
||||
const authorization = req.headers.get('Authorization');
|
||||
const result = (authorization as string).split(' ');
|
||||
token = result[1];
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const data = JWT.parseToken(token);
|
||||
|
||||
return JWT.isExpired(data, now) ? null : data.user;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
createToken(user: User, expiresIn = 0) {
|
||||
const exp = user.refresh_token ? currentTimestamp() + expiresIn : undefined;
|
||||
|
||||
return [
|
||||
base64.encode(JSON.stringify({ typ: 'JWT', alg: 'HS256' })),
|
||||
base64.encode(JSON.stringify(filterObject(Object.assign({ exp, user })))),
|
||||
base64.encode('ng-matero'),
|
||||
].join('.');
|
||||
}
|
||||
|
||||
private static parseToken(accessToken: string) {
|
||||
const [, payload] = accessToken.split('.');
|
||||
|
||||
return JSON.parse(base64.decode(payload));
|
||||
}
|
||||
|
||||
private static isExpired(data: any, now: Date) {
|
||||
const expiresIn = new Date();
|
||||
expiresIn.setTime(data.exp * 1000);
|
||||
const diff = this.dateToSeconds(expiresIn) - this.dateToSeconds(now);
|
||||
|
||||
return diff <= 0;
|
||||
}
|
||||
|
||||
private static dateToSeconds(date: Date) {
|
||||
return Math.ceil(date.getTime() / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
const jwt = new JWT();
|
||||
|
||||
function is(reqInfo: RequestInfo, path: string) {
|
||||
if (environment.baseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new RegExp(`${path}(/)?$`, 'i').test(reqInfo.req.url);
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class InMemDataService implements InMemoryDbService {
|
||||
private users: User[] = [
|
||||
{
|
||||
id: 1,
|
||||
username: 'ng-matero',
|
||||
password: 'ng-matero',
|
||||
name: 'Zongbin',
|
||||
email: 'nzb329@163.com',
|
||||
avatar: './assets/images/avatar.jpg',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'recca0120',
|
||||
password: 'password',
|
||||
name: 'recca0120',
|
||||
email: 'recca0120@gmail.com',
|
||||
avatar: './assets/images/avatars/avatar-10.jpg',
|
||||
refresh_token: true,
|
||||
},
|
||||
];
|
||||
|
||||
createDb(
|
||||
reqInfo?: RequestInfo
|
||||
):
|
||||
| Record<string, unknown>
|
||||
| Observable<Record<string, unknown>>
|
||||
| Promise<Record<string, unknown>> {
|
||||
return { users: this.users };
|
||||
}
|
||||
|
||||
get(reqInfo: RequestInfo) {
|
||||
const { headers, url } = reqInfo;
|
||||
|
||||
if (is(reqInfo, 'sanctum/csrf-cookie')) {
|
||||
const response = { headers, url, status: STATUS.NO_CONTENT, body: {} };
|
||||
|
||||
return reqInfo.utils.createResponse$(() => response);
|
||||
}
|
||||
|
||||
if (is(reqInfo, 'me/menu')) {
|
||||
return ajax('assets/data/menu.json?_t=' + Date.now()).pipe(
|
||||
map((response: any) => {
|
||||
return { headers, url, status: STATUS.OK, body: { menu: response.response.menu } };
|
||||
}),
|
||||
switchMap(response => reqInfo.utils.createResponse$(() => response))
|
||||
);
|
||||
}
|
||||
|
||||
if (is(reqInfo, 'me')) {
|
||||
const user = jwt.getUser(reqInfo.req as HttpRequest<any>);
|
||||
const result = user
|
||||
? { status: STATUS.OK, body: user }
|
||||
: { status: STATUS.UNAUTHORIZED, body: {} };
|
||||
const response = Object.assign({ headers, url }, result);
|
||||
|
||||
return reqInfo.utils.createResponse$(() => response);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
post(reqInfo: RequestInfo) {
|
||||
if (is(reqInfo, 'auth/login')) {
|
||||
return this.login(reqInfo);
|
||||
}
|
||||
|
||||
if (is(reqInfo, 'auth/refresh')) {
|
||||
return this.refresh(reqInfo);
|
||||
}
|
||||
|
||||
if (is(reqInfo, 'auth/logout')) {
|
||||
return this.logout(reqInfo);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private login(reqInfo: RequestInfo) {
|
||||
const { headers, url } = reqInfo;
|
||||
const req = reqInfo.req as HttpRequest<any>;
|
||||
const { username, password } = req.body;
|
||||
|
||||
return from(this.users).pipe(
|
||||
find(user => user.username === username || user.email === username),
|
||||
map(user => {
|
||||
if (!user) {
|
||||
return { headers, url, status: STATUS.UNAUTHORIZED, body: {} };
|
||||
}
|
||||
|
||||
if (user.password !== password) {
|
||||
const result = {
|
||||
status: STATUS.UNPROCESSABLE_ENTRY,
|
||||
error: { errors: { password: ['The provided password is incorrect.'] } },
|
||||
};
|
||||
|
||||
return Object.assign({ headers, url }, result);
|
||||
}
|
||||
|
||||
const currentUser = Object.assign({}, user);
|
||||
delete currentUser.password;
|
||||
return { headers, url, status: STATUS.OK, body: jwt.generate(currentUser) };
|
||||
}),
|
||||
switchMap(response => reqInfo.utils.createResponse$(() => response))
|
||||
);
|
||||
}
|
||||
|
||||
private refresh(reqInfo: RequestInfo) {
|
||||
const { headers, url } = reqInfo;
|
||||
const user = jwt.getUser(reqInfo.req as HttpRequest<any>);
|
||||
const result = user
|
||||
? { status: STATUS.OK, body: jwt.generate(user) }
|
||||
: { status: STATUS.UNAUTHORIZED, body: {} };
|
||||
const response = Object.assign({ headers, url }, result);
|
||||
|
||||
return reqInfo.utils.createResponse$(() => response);
|
||||
}
|
||||
|
||||
private logout(reqInfo: RequestInfo) {
|
||||
const { headers, url } = reqInfo;
|
||||
const response = { headers, url, status: STATUS.OK, body: {} };
|
||||
|
||||
return reqInfo.utils.createResponse$(() => response);
|
||||
}
|
||||
}
|
||||
12
front/app/src/app/shared/index.ts
Normal file
12
front/app/src/app/shared/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Module
|
||||
export * from './shared.module';
|
||||
|
||||
// Services
|
||||
export * from './services/directionality.service';
|
||||
export * from './services/message.service';
|
||||
export * from './services/storage.service';
|
||||
export * from './services/paginator-i18n.service';
|
||||
|
||||
// Utils
|
||||
export * from './utils/colors';
|
||||
export * from './utils/icons';
|
||||
10
front/app/src/app/shared/pipes/safe-url.pipe.ts
Normal file
10
front/app/src/app/shared/pipes/safe-url.pipe.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
|
||||
@Pipe({ name: 'safeUrl' })
|
||||
export class SafeUrlPipe implements PipeTransform {
|
||||
constructor(private sanitizer: DomSanitizer) {}
|
||||
transform(url: string) {
|
||||
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
|
||||
}
|
||||
}
|
||||
9
front/app/src/app/shared/pipes/to-observable.pipe.ts
Normal file
9
front/app/src/app/shared/pipes/to-observable.pipe.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { Observable, of, isObservable } from 'rxjs';
|
||||
|
||||
@Pipe({ name: 'toObservable' })
|
||||
export class ToObservablePipe implements PipeTransform {
|
||||
transform(value: Observable<any> | unknown): Observable<any> {
|
||||
return isObservable(value) ? value : of(value);
|
||||
}
|
||||
}
|
||||
22
front/app/src/app/shared/services/directionality.service.ts
Normal file
22
front/app/src/app/shared/services/directionality.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Direction, Directionality } from '@angular/cdk/bidi';
|
||||
import { EventEmitter, Injectable, OnDestroy } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AppDirectionality implements Directionality, OnDestroy {
|
||||
readonly change = new EventEmitter<Direction>();
|
||||
|
||||
get value(): Direction {
|
||||
return this._value;
|
||||
}
|
||||
set value(value: Direction) {
|
||||
this._value = value;
|
||||
this.change.next(value);
|
||||
}
|
||||
private _value: Direction = 'ltr';
|
||||
|
||||
ngOnDestroy() {
|
||||
this.change.complete();
|
||||
}
|
||||
}
|
||||
16
front/app/src/app/shared/services/message.service.ts
Normal file
16
front/app/src/app/shared/services/message.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MessageService {
|
||||
messages: string[] = [];
|
||||
|
||||
add(message: string) {
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.messages = [];
|
||||
}
|
||||
}
|
||||
45
front/app/src/app/shared/services/paginator-i18n.service.ts
Normal file
45
front/app/src/app/shared/services/paginator-i18n.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { MatPaginatorIntl } from '@angular/material/paginator';
|
||||
import { LangChangeEvent, TranslateService } from '@ngx-translate/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class PaginatorI18nService {
|
||||
paginatorIntl = new MatPaginatorIntl();
|
||||
|
||||
constructor(private translate: TranslateService) {
|
||||
this.translate.onLangChange.subscribe((event: LangChangeEvent) => this.getPaginatorIntl());
|
||||
}
|
||||
|
||||
getPaginatorIntl() {
|
||||
this.paginatorIntl.itemsPerPageLabel = this.translate.instant('paginator.items_per_page_label');
|
||||
this.paginatorIntl.previousPageLabel = this.translate.instant('paginator.previous_page_label');
|
||||
this.paginatorIntl.nextPageLabel = this.translate.instant('paginator.next_page_label');
|
||||
this.paginatorIntl.firstPageLabel = this.translate.instant('paginator.first_page_label');
|
||||
this.paginatorIntl.lastPageLabel = this.translate.instant('paginator.last_page_label');
|
||||
this.paginatorIntl.getRangeLabel = this.getRangeLabel.bind(this);
|
||||
|
||||
this.paginatorIntl.changes.next();
|
||||
|
||||
return this.paginatorIntl;
|
||||
}
|
||||
|
||||
private getRangeLabel(page: number, pageSize: number, length: number): string {
|
||||
if (length === 0 || pageSize === 0) {
|
||||
return this.translate.instant('paginator.range_page_label_1', { length });
|
||||
}
|
||||
length = Math.max(length, 0);
|
||||
|
||||
const startIndex = page * pageSize;
|
||||
// If the start index exceeds the list length, do not try and fix the end index to the end.
|
||||
const endIndex =
|
||||
startIndex < length ? Math.min(startIndex + pageSize, length) : startIndex + pageSize;
|
||||
|
||||
return this.translate.instant('paginator.range_page_label_2', {
|
||||
startIndex: startIndex + 1,
|
||||
endIndex,
|
||||
length,
|
||||
});
|
||||
}
|
||||
}
|
||||
53
front/app/src/app/shared/services/storage.service.ts
Normal file
53
front/app/src/app/shared/services/storage.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LocalStorageService {
|
||||
get(key: string) {
|
||||
return JSON.parse(localStorage.getItem(key) || '{}') || {};
|
||||
}
|
||||
|
||||
set(key: string, value: any): boolean {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return !!localStorage.getItem(key);
|
||||
}
|
||||
|
||||
remove(key: string) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
localStorage.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryStorageService {
|
||||
private store: { [k: string]: string } = {};
|
||||
|
||||
get(key: string) {
|
||||
return JSON.parse(this.store[key] || '{}') || {};
|
||||
}
|
||||
|
||||
set(key: string, value: any): boolean {
|
||||
this.store[key] = JSON.stringify(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return !!this.store[key];
|
||||
}
|
||||
|
||||
remove(key: string) {
|
||||
delete this.store[key];
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store = {};
|
||||
}
|
||||
}
|
||||
51
front/app/src/app/shared/shared.module.ts
Normal file
51
front/app/src/app/shared/shared.module.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
|
||||
|
||||
import { MaterialModule } from '../material.module';
|
||||
import { MaterialExtensionsModule } from '../material-extensions.module';
|
||||
|
||||
import { FormlyModule } from '@ngx-formly/core';
|
||||
import { FormlyMaterialModule } from '@ngx-formly/material';
|
||||
import { NgProgressModule } from 'ngx-progressbar';
|
||||
import { NgProgressHttpModule } from 'ngx-progressbar/http';
|
||||
import { NgProgressRouterModule } from 'ngx-progressbar/router';
|
||||
import { NgxPermissionsModule } from 'ngx-permissions';
|
||||
import { ToastrModule } from 'ngx-toastr';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { BreadcrumbComponent } from './components/breadcrumb/breadcrumb.component';
|
||||
import { PageHeaderComponent } from './components/page-header/page-header.component';
|
||||
import { ErrorCodeComponent } from './components/error-code/error-code.component';
|
||||
import { DisableControlDirective } from './directives/disable-control.directive';
|
||||
import { SafeUrlPipe } from './pipes/safe-url.pipe';
|
||||
import { ToObservablePipe } from './pipes/to-observable.pipe';
|
||||
|
||||
const MODULES: any[] = [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
MaterialModule,
|
||||
MaterialExtensionsModule,
|
||||
FormlyModule,
|
||||
FormlyMaterialModule,
|
||||
NgProgressModule,
|
||||
NgProgressRouterModule,
|
||||
NgProgressHttpModule,
|
||||
NgxPermissionsModule,
|
||||
ToastrModule,
|
||||
TranslateModule,
|
||||
];
|
||||
const COMPONENTS: any[] = [BreadcrumbComponent, PageHeaderComponent, ErrorCodeComponent];
|
||||
const COMPONENTS_DYNAMIC: any[] = [];
|
||||
const DIRECTIVES: any[] = [DisableControlDirective];
|
||||
const PIPES: any[] = [SafeUrlPipe, ToObservablePipe];
|
||||
|
||||
@NgModule({
|
||||
imports: [...MODULES],
|
||||
exports: [...MODULES, ...COMPONENTS, ...DIRECTIVES, ...PIPES],
|
||||
declarations: [...COMPONENTS, ...COMPONENTS_DYNAMIC, ...DIRECTIVES, ...PIPES],
|
||||
})
|
||||
export class SharedModule {}
|
||||
610
front/app/src/app/shared/utils/colors.ts
Normal file
610
front/app/src/app/shared/utils/colors.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
export const MAT_COLORS = {
|
||||
'red': {
|
||||
50: '#FFEBEE',
|
||||
100: '#FFCDD2',
|
||||
200: '#EF9A9A',
|
||||
300: '#E57373',
|
||||
400: '#EF5350',
|
||||
500: '#F44336',
|
||||
600: '#E53935',
|
||||
700: '#D32F2F',
|
||||
800: '#C62828',
|
||||
900: '#B71C1C',
|
||||
A100: '#FF8A80',
|
||||
A200: '#FF5252',
|
||||
A400: '#FF1744',
|
||||
A700: '#D50000',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'light',
|
||||
500: 'light',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'light',
|
||||
A400: 'light',
|
||||
A700: 'light',
|
||||
},
|
||||
},
|
||||
'pink': {
|
||||
50: '#FCE4EC',
|
||||
100: '#F8BBD0',
|
||||
200: '#F48FB1',
|
||||
300: '#F06292',
|
||||
400: '#EC407A',
|
||||
500: '#E91E63',
|
||||
600: '#D81B60',
|
||||
700: '#C2185B',
|
||||
800: '#AD1457',
|
||||
900: '#880E4F',
|
||||
A100: '#FF80AB',
|
||||
A200: '#FF4081',
|
||||
A400: '#F50057',
|
||||
A700: '#C51162',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'light',
|
||||
500: 'light',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'light',
|
||||
A400: 'light',
|
||||
A700: 'light',
|
||||
},
|
||||
},
|
||||
'purple': {
|
||||
50: '#F3E5F5',
|
||||
100: '#E1BEE7',
|
||||
200: '#CE93D8',
|
||||
300: '#BA68C8',
|
||||
400: '#AB47BC',
|
||||
500: '#9C27B0',
|
||||
600: '#8E24AA',
|
||||
700: '#7B1FA2',
|
||||
800: '#6A1B9A',
|
||||
900: '#4A148C',
|
||||
A100: '#EA80FC',
|
||||
A200: '#E040FB',
|
||||
A400: '#D500F9',
|
||||
A700: '#AA00FF',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'light',
|
||||
400: 'light',
|
||||
500: 'light',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'light',
|
||||
A400: 'light',
|
||||
A700: 'light',
|
||||
},
|
||||
},
|
||||
'deep-purple': {
|
||||
50: '#EDE7F6',
|
||||
100: '#D1C4E9',
|
||||
200: '#B39DDB',
|
||||
300: '#9575CD',
|
||||
400: '#7E57C2',
|
||||
500: '#673AB7',
|
||||
600: '#5E35B1',
|
||||
700: '#512DA8',
|
||||
800: '#4527A0',
|
||||
900: '#311B92',
|
||||
A100: '#B388FF',
|
||||
A200: '#7C4DFF',
|
||||
A400: '#651FFF',
|
||||
A700: '#6200EA',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'light',
|
||||
400: 'light',
|
||||
500: 'light',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'light',
|
||||
A400: 'light',
|
||||
A700: 'light',
|
||||
},
|
||||
},
|
||||
'indigo': {
|
||||
50: '#E8EAF6',
|
||||
100: '#C5CAE9',
|
||||
200: '#9FA8DA',
|
||||
300: '#7986CB',
|
||||
400: '#5C6BC0',
|
||||
500: '#3F51B5',
|
||||
600: '#3949AB',
|
||||
700: '#303F9F',
|
||||
800: '#283593',
|
||||
900: '#1A237E',
|
||||
A100: '#8C9EFF',
|
||||
A200: '#536DFE',
|
||||
A400: '#3D5AFE',
|
||||
A700: '#304FFE',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'light',
|
||||
400: 'light',
|
||||
500: 'light',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'light',
|
||||
A400: 'light',
|
||||
A700: 'light',
|
||||
},
|
||||
},
|
||||
'blue': {
|
||||
50: '#E3F2FD',
|
||||
100: '#BBDEFB',
|
||||
200: '#90CAF9',
|
||||
300: '#64B5F6',
|
||||
400: '#42A5F5',
|
||||
500: '#2196F3',
|
||||
600: '#1E88E5',
|
||||
700: '#1976D2',
|
||||
800: '#1565C0',
|
||||
900: '#0D47A1',
|
||||
A100: '#82B1FF',
|
||||
A200: '#448AFF',
|
||||
A400: '#2979FF',
|
||||
A700: '#2962FF',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'light',
|
||||
A400: 'light',
|
||||
A700: 'light',
|
||||
},
|
||||
},
|
||||
'light-blue': {
|
||||
50: '#E1F5FE',
|
||||
100: '#B3E5FC',
|
||||
200: '#81D4FA',
|
||||
300: '#4FC3F7',
|
||||
400: '#29B6F6',
|
||||
500: '#03A9F4',
|
||||
600: '#039BE5',
|
||||
700: '#0288D1',
|
||||
800: '#0277BD',
|
||||
900: '#01579B',
|
||||
A100: '#80D8FF',
|
||||
A200: '#40C4FF',
|
||||
A400: '#00B0FF',
|
||||
A700: '#0091EA',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'dark',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'light',
|
||||
},
|
||||
},
|
||||
'cyan': {
|
||||
50: '#E0F7FA',
|
||||
100: '#B2EBF2',
|
||||
200: '#80DEEA',
|
||||
300: '#4DD0E1',
|
||||
400: '#26C6DA',
|
||||
500: '#00BCD4',
|
||||
600: '#00ACC1',
|
||||
700: '#0097A7',
|
||||
800: '#00838F',
|
||||
900: '#006064',
|
||||
A100: '#84FFFF',
|
||||
A200: '#18FFFF',
|
||||
A400: '#00E5FF',
|
||||
A700: '#00B8D4',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'dark',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'dark',
|
||||
},
|
||||
},
|
||||
'teal': {
|
||||
50: '#E0F2F1',
|
||||
100: '#B2DFDB',
|
||||
200: '#80CBC4',
|
||||
300: '#4DB6AC',
|
||||
400: '#26A69A',
|
||||
500: '#009688',
|
||||
600: '#00897B',
|
||||
700: '#00796B',
|
||||
800: '#00695C',
|
||||
900: '#004D40',
|
||||
A100: '#A7FFEB',
|
||||
A200: '#64FFDA',
|
||||
A400: '#1DE9B6',
|
||||
A700: '#00BFA5',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'light',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'dark',
|
||||
},
|
||||
},
|
||||
'green': {
|
||||
50: '#E8F5E9',
|
||||
100: '#C8E6C9',
|
||||
200: '#A5D6A7',
|
||||
300: '#81C784',
|
||||
400: '#66BB6A',
|
||||
500: '#4CAF50',
|
||||
600: '#43A047',
|
||||
700: '#388E3C',
|
||||
800: '#2E7D32',
|
||||
900: '#1B5E20',
|
||||
A100: '#B9F6CA',
|
||||
A200: '#69F0AE',
|
||||
A400: '#00E676',
|
||||
A700: '#00C853',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'dark',
|
||||
},
|
||||
},
|
||||
'light-green': {
|
||||
50: '#F1F8E9',
|
||||
100: '#DCEDC8',
|
||||
200: '#C5E1A5',
|
||||
300: '#AED581',
|
||||
400: '#9CCC65',
|
||||
500: '#8BC34A',
|
||||
600: '#7CB342',
|
||||
700: '#689F38',
|
||||
800: '#558B2F',
|
||||
900: '#33691E',
|
||||
A100: '#CCFF90',
|
||||
A200: '#B2FF59',
|
||||
A400: '#76FF03',
|
||||
A700: '#64DD17',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'dark',
|
||||
700: 'dark',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'dark',
|
||||
},
|
||||
},
|
||||
'lime': {
|
||||
50: '#F9FBE7',
|
||||
100: '#F0F4C3',
|
||||
200: '#E6EE9C',
|
||||
300: '#DCE775',
|
||||
400: '#D4E157',
|
||||
500: '#CDDC39',
|
||||
600: '#C0CA33',
|
||||
700: '#AFB42B',
|
||||
800: '#9E9D24',
|
||||
900: '#827717',
|
||||
A100: '#F4FF81',
|
||||
A200: '#EEFF41',
|
||||
A400: '#C6FF00',
|
||||
A700: '#AEEA00',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'dark',
|
||||
700: 'dark',
|
||||
800: 'dark',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'dark',
|
||||
},
|
||||
},
|
||||
'yellow': {
|
||||
50: '#FFFDE7',
|
||||
100: '#FFF9C4',
|
||||
200: '#FFF59D',
|
||||
300: '#FFF176',
|
||||
400: '#FFEE58',
|
||||
500: '#FFEB3B',
|
||||
600: '#FDD835',
|
||||
700: '#FBC02D',
|
||||
800: '#F9A825',
|
||||
900: '#F57F17',
|
||||
A100: '#FFFF8D',
|
||||
A200: '#FFFF00',
|
||||
A400: '#FFEA00',
|
||||
A700: '#FFD600',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'dark',
|
||||
700: 'dark',
|
||||
800: 'dark',
|
||||
900: 'dark',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'dark',
|
||||
},
|
||||
},
|
||||
'amber': {
|
||||
50: '#FFF8E1',
|
||||
100: '#FFECB3',
|
||||
200: '#FFE082',
|
||||
300: '#FFD54F',
|
||||
400: '#FFCA28',
|
||||
500: '#FFC107',
|
||||
600: '#FFB300',
|
||||
700: '#FFA000',
|
||||
800: '#FF8F00',
|
||||
900: '#FF6F00',
|
||||
A100: '#FFE57F',
|
||||
A200: '#FFD740',
|
||||
A400: '#FFC400',
|
||||
A700: '#FFAB00',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'dark',
|
||||
700: 'dark',
|
||||
800: 'dark',
|
||||
900: 'dark',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'dark',
|
||||
},
|
||||
},
|
||||
'orange': {
|
||||
50: '#FFF3E0',
|
||||
100: '#FFE0B2',
|
||||
200: '#FFCC80',
|
||||
300: '#FFB74D',
|
||||
400: '#FFA726',
|
||||
500: '#FF9800',
|
||||
600: '#FB8C00',
|
||||
700: '#F57C00',
|
||||
800: '#EF6C00',
|
||||
900: '#E65100',
|
||||
A100: '#FFD180',
|
||||
A200: '#FFAB40',
|
||||
A400: '#FF9100',
|
||||
A700: '#FF6D00',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'dark',
|
||||
700: 'dark',
|
||||
800: 'dark',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'dark',
|
||||
},
|
||||
},
|
||||
'deep-orange': {
|
||||
50: '#FBE9E7',
|
||||
100: '#FFCCBC',
|
||||
200: '#FFAB91',
|
||||
300: '#FF8A65',
|
||||
400: '#FF7043',
|
||||
500: '#FF5722',
|
||||
600: '#F4511E',
|
||||
700: '#E64A19',
|
||||
800: '#D84315',
|
||||
900: '#BF360C',
|
||||
A100: '#FF9E80',
|
||||
A200: '#FF6E40',
|
||||
A400: '#FF3D00',
|
||||
A700: '#DD2C00',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'dark',
|
||||
700: 'dark',
|
||||
800: 'dark',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'dark',
|
||||
},
|
||||
},
|
||||
'brown': {
|
||||
50: '#EFEBE9',
|
||||
100: '#D7CCC8',
|
||||
200: '#BCAAA4',
|
||||
300: '#A1887F',
|
||||
400: '#8D6E63',
|
||||
500: '#795548',
|
||||
600: '#6D4C41',
|
||||
700: '#5D4037',
|
||||
800: '#4E342E',
|
||||
900: '#3E2723',
|
||||
A100: '#D7CCC8',
|
||||
A200: '#BCAAA4',
|
||||
A400: '#8D6E63',
|
||||
A700: '#5D4037',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'light',
|
||||
400: 'light',
|
||||
500: 'light',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'light',
|
||||
A700: 'light',
|
||||
},
|
||||
},
|
||||
'gray': {
|
||||
50: '#FAFAFA',
|
||||
100: '#F5F5F5',
|
||||
200: '#EEEEEE',
|
||||
300: '#E0E0E0',
|
||||
400: '#BDBDBD',
|
||||
500: '#9E9E9E',
|
||||
600: '#757575',
|
||||
700: '#616161',
|
||||
800: '#424242',
|
||||
900: '#212121',
|
||||
A100: '#FFFFFF',
|
||||
A200: ' #EEEEEE',
|
||||
A400: '#BDBDBD',
|
||||
A700: '#616161',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'dark',
|
||||
500: 'dark',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'dark',
|
||||
A700: 'light',
|
||||
},
|
||||
},
|
||||
'blue-gray': {
|
||||
50: '#ECEFF1',
|
||||
100: '#CFD8DC',
|
||||
200: '#B0BEC5',
|
||||
300: '#90A4AE',
|
||||
400: '#78909C',
|
||||
500: '#607D8B',
|
||||
600: '#546E7A',
|
||||
700: '#455A64',
|
||||
800: '#37474F',
|
||||
900: '#263238',
|
||||
A100: '#CFD8DC',
|
||||
A200: '#B0BEC5',
|
||||
A400: '#78909C',
|
||||
A700: '#455A64',
|
||||
contrast: {
|
||||
50: 'dark',
|
||||
100: 'dark',
|
||||
200: 'dark',
|
||||
300: 'dark',
|
||||
400: 'light',
|
||||
500: 'light',
|
||||
600: 'light',
|
||||
700: 'light',
|
||||
800: 'light',
|
||||
900: 'light',
|
||||
A100: 'dark',
|
||||
A200: 'dark',
|
||||
A400: 'light',
|
||||
A700: 'light',
|
||||
},
|
||||
},
|
||||
};
|
||||
1072
front/app/src/app/shared/utils/icons.ts
Normal file
1072
front/app/src/app/shared/utils/icons.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
|
||||
<div class="matero-container-wrap matero-navbar-side matero-header-fixed "
|
||||
[ngClass]="{'matero-sidenav-collapsed': options.sidenavCollapsed}"
|
||||
dir="ltr">
|
||||
<ng-progress></ng-progress>
|
||||
|
||||
<mat-sidenav-container class="matero-container" autosize autoFocus>
|
||||
<mat-sidenav #sidenav class="matero-sidenav"
|
||||
[mode]="isOver ? 'over' : 'side'"
|
||||
[opened]="options.sidenavOpened && !isOver"
|
||||
(openedChange)="onSidenavOpenedChange($event)"
|
||||
(closedStart)="onSidenavClosedStart()">
|
||||
<app-sidebar [showToggle]="!isOver"
|
||||
(toggleCollapsed)="toggleCollapsed()"
|
||||
[toggleChecked]="!!options.sidenavCollapsed">
|
||||
</app-sidebar>
|
||||
</mat-sidenav>
|
||||
|
||||
<mat-sidenav #sidenavNotice position="end" mode="over">
|
||||
<app-sidebar-notice></app-sidebar-notice>
|
||||
</mat-sidenav>
|
||||
|
||||
<mat-sidenav-content #content class="matero-content-wrap">
|
||||
<app-header [showToggle]="!options.sidenavCollapsed"
|
||||
(toggleSidenav)="sidenav.toggle()"
|
||||
(toggleSidenavNotice)="sidenavNotice.toggle()">
|
||||
</app-header>
|
||||
|
||||
<div class="matero-content">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
</div>
|
||||
160
front/app/src/app/theme/admin-layout/admin-layout.component.scss
Normal file
160
front/app/src/app/theme/admin-layout/admin-layout.component.scss
Normal file
@@ -0,0 +1,160 @@
|
||||
@use '@angular/material' as mat;
|
||||
@use '../style/variables';
|
||||
@use '../style/transitions';
|
||||
@use '../style/breakpoints';
|
||||
|
||||
.matero-container-wrap,
|
||||
.matero-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.matero-content {
|
||||
position: relative;
|
||||
padding: variables.$gutter;
|
||||
}
|
||||
|
||||
.matero-sidenav {
|
||||
position: absolute;
|
||||
overflow-x: hidden;
|
||||
transition: transitions.swift-ease-out(width); // Only set width property
|
||||
|
||||
@include mat.elevation(2);
|
||||
|
||||
&.mat-drawer-side {
|
||||
border-width: 0;
|
||||
|
||||
[dir='rtl'] & {
|
||||
border-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layout control
|
||||
.matero-header-above {
|
||||
.matero-container {
|
||||
height: calc(100% - #{variables.$toolbar-height-desktop}) !important;
|
||||
}
|
||||
|
||||
.matero-sidebar-main {
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Layout control
|
||||
.matero-sidenav-collapsed,
|
||||
.matero-sidenav-collapsed-fix {
|
||||
.matero-sidenav {
|
||||
width: variables.$sidenav-collapsed-width;
|
||||
|
||||
.menu-name,
|
||||
.menu-label,
|
||||
.menu-badge,
|
||||
.menu-caret,
|
||||
.matero-user-panel-name,
|
||||
.matero-user-panel-email,
|
||||
.matero-user-panel-icons {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.matero-user-panel-avatar {
|
||||
transform: scale(.5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
width: variables.$sidenav-width;
|
||||
|
||||
.menu-name,
|
||||
.menu-label,
|
||||
.menu-badge,
|
||||
.menu-caret,
|
||||
.matero-user-panel-name,
|
||||
.matero-user-panel-email,
|
||||
.matero-user-panel-icons {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.matero-user-panel-avatar {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layout control
|
||||
.matero-sidenav-collapsed {
|
||||
.matero-content-wrap {
|
||||
margin-left: variables.$sidenav-collapsed-width !important;
|
||||
|
||||
[dir='rtl'] & {
|
||||
margin-right: variables.$sidenav-collapsed-width !important;
|
||||
margin-left: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
&[dir='rtl'] .matero-content-wrap {
|
||||
margin-right: variables.$sidenav-collapsed-width !important;
|
||||
margin-left: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Layout control
|
||||
.matero-navbar-top {
|
||||
.matero-topmenu {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.matero-branding {
|
||||
margin-left: 16px;
|
||||
|
||||
[dir='rtl'] & {
|
||||
margin-right: 16px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layout control
|
||||
.matero-header-fixed {
|
||||
.matero-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.matero-topmenu {
|
||||
top: variables.$topmenu-sticky-position-desktop;
|
||||
|
||||
@include breakpoints.bp-lt(small) {
|
||||
& {
|
||||
top: variables.$topmenu-sticky-position-mobile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.matero-navbar-side {
|
||||
.matero-toolbar {
|
||||
border-bottom: unset;
|
||||
|
||||
@include mat.elevation(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fix the init content width
|
||||
.matero-content-width-fix {
|
||||
.matero-content-wrap {
|
||||
margin-left: variables.$sidenav-width !important;
|
||||
|
||||
[dir='rtl'] & {
|
||||
margin-right: variables.$sidenav-width !important;
|
||||
margin-left: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Colorful
|
||||
.matero-header-white {
|
||||
.matero-toolbar,
|
||||
.matero-topmenu {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
163
front/app/src/app/theme/admin-layout/admin-layout.component.ts
Normal file
163
front/app/src/app/theme/admin-layout/admin-layout.component.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
Component,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
HostBinding,
|
||||
Inject,
|
||||
Optional,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { BreakpointObserver, MediaMatcher } from '@angular/cdk/layout';
|
||||
import { Directionality } from '@angular/cdk/bidi';
|
||||
import { MatSidenav, MatSidenavContent } from '@angular/material/sidenav';
|
||||
|
||||
import { SettingsService, AppSettings } from '@core';
|
||||
import { AppDirectionality } from '@shared';
|
||||
|
||||
const MOBILE_MEDIAQUERY = 'screen and (max-width: 599px)';
|
||||
const TABLET_MEDIAQUERY = 'screen and (min-width: 600px) and (max-width: 959px)';
|
||||
const MONITOR_MEDIAQUERY = 'screen and (min-width: 960px)';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-layout',
|
||||
templateUrl: './admin-layout.component.html',
|
||||
styleUrls: ['./admin-layout.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class AdminLayoutComponent implements OnDestroy {
|
||||
@ViewChild('sidenav', { static: true }) sidenav!: MatSidenav;
|
||||
@ViewChild('content', { static: true }) content!: MatSidenavContent;
|
||||
|
||||
options = this.settings.getOptions();
|
||||
|
||||
private layoutChangesSubscription = Subscription.EMPTY;
|
||||
|
||||
get isOver(): boolean {
|
||||
return this.isMobileScreen;
|
||||
}
|
||||
|
||||
private isMobileScreen = false;
|
||||
|
||||
@HostBinding('class.matero-content-width-fix') get contentWidthFix() {
|
||||
return (
|
||||
this.isContentWidthFixed &&
|
||||
this.options.navPos === 'side' &&
|
||||
this.options.sidenavOpened &&
|
||||
!this.isOver
|
||||
);
|
||||
}
|
||||
|
||||
private isContentWidthFixed = true;
|
||||
|
||||
@HostBinding('class.matero-sidenav-collapsed-fix') get collapsedWidthFix() {
|
||||
return (
|
||||
this.isCollapsedWidthFixed &&
|
||||
(this.options.navPos === 'top' || (this.options.sidenavOpened && this.isOver))
|
||||
);
|
||||
}
|
||||
|
||||
private isCollapsedWidthFixed = false;
|
||||
|
||||
private htmlElement!: HTMLHtmlElement;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private mediaMatcher: MediaMatcher,
|
||||
private breakpointObserver: BreakpointObserver,
|
||||
private settings: SettingsService,
|
||||
@Optional() @Inject(DOCUMENT) private document: Document,
|
||||
@Inject(Directionality) public dir: AppDirectionality
|
||||
) {
|
||||
this.dir.value = this.options.dir;
|
||||
this.document.body.dir = this.dir.value;
|
||||
|
||||
this.htmlElement = this.document.querySelector('html')!;
|
||||
|
||||
this.layoutChangesSubscription = this.breakpointObserver
|
||||
.observe([MOBILE_MEDIAQUERY, TABLET_MEDIAQUERY, MONITOR_MEDIAQUERY])
|
||||
.subscribe(state => {
|
||||
// SidenavOpened must be reset true when layout changes
|
||||
this.options.sidenavOpened = true;
|
||||
|
||||
this.isMobileScreen = state.breakpoints[MOBILE_MEDIAQUERY];
|
||||
this.options.sidenavCollapsed = state.breakpoints[TABLET_MEDIAQUERY];
|
||||
this.isContentWidthFixed = state.breakpoints[MONITOR_MEDIAQUERY];
|
||||
});
|
||||
|
||||
this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(e => {
|
||||
if (this.isOver) {
|
||||
this.sidenav.close();
|
||||
}
|
||||
this.content.scrollTo({ top: 0 });
|
||||
});
|
||||
|
||||
if (this.options.theme === 'auto') {
|
||||
this.setAutoTheme();
|
||||
}
|
||||
|
||||
// Initialize project theme with options
|
||||
this.receiveOptions(this.options);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.layoutChangesSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
toggleCollapsed() {
|
||||
this.isContentWidthFixed = false;
|
||||
this.options.sidenavCollapsed = !this.options.sidenavCollapsed;
|
||||
this.resetCollapsedState();
|
||||
}
|
||||
|
||||
// TODO: Trigger when transition end
|
||||
resetCollapsedState(timer = 400) {
|
||||
setTimeout(() => this.settings.setOptions(this.options), timer);
|
||||
}
|
||||
|
||||
onSidenavClosedStart() {
|
||||
this.isContentWidthFixed = false;
|
||||
}
|
||||
|
||||
onSidenavOpenedChange(isOpened: boolean) {
|
||||
this.isCollapsedWidthFixed = !this.isOver;
|
||||
this.options.sidenavOpened = isOpened;
|
||||
this.settings.setOptions(this.options);
|
||||
}
|
||||
|
||||
setAutoTheme() {
|
||||
// Check whether the browser support `prefers-color-scheme`
|
||||
if (this.mediaMatcher.matchMedia('(prefers-color-scheme)').media !== 'not all') {
|
||||
const isSystemDark = this.mediaMatcher.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
// Set theme to dark if `prefers-color-scheme` is dark. Otherwise, set it to light.
|
||||
this.options.theme = isSystemDark ? 'dark' : 'light';
|
||||
} else {
|
||||
// If the browser does not support `prefers-color-scheme`, set the default to light.
|
||||
this.options.theme = 'light';
|
||||
}
|
||||
}
|
||||
|
||||
// Demo purposes only
|
||||
|
||||
receiveOptions(options: AppSettings): void {
|
||||
this.options = options;
|
||||
this.toggleDarkTheme(options);
|
||||
this.toggleDirection(options);
|
||||
}
|
||||
|
||||
toggleDarkTheme(options: AppSettings) {
|
||||
if (options.theme === 'dark') {
|
||||
this.htmlElement.classList.add('theme-dark');
|
||||
} else {
|
||||
this.htmlElement.classList.remove('theme-dark');
|
||||
}
|
||||
}
|
||||
|
||||
toggleDirection(options: AppSettings) {
|
||||
this.dir.value = options.dir;
|
||||
this.document.body.dir = this.dir.value;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user