Installing ng-matero

This commit is contained in:
2023-01-09 16:39:23 +01:00
parent 2425ecbd3b
commit 399b52a272
209 changed files with 15574 additions and 174 deletions

View File

@@ -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 { }

View File

@@ -1,3 +0,0 @@
<app-auth></app-auth>
<router-outlet></router-outlet>

View File

@@ -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!');
});
});

View File

@@ -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();
}
}

View File

@@ -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 {}

View File

@@ -1 +0,0 @@
<p>auth works!</p>

View File

@@ -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();
});
});

View File

@@ -1,10 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-auth',
templateUrl: './auth.component.html',
styleUrls: ['./auth.component.css']
})
export class AuthComponent {
}

View 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`

View 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'));
});
});

View 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');
}
}

View 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({});
});
});

View 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)));
}
}

View 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;
}

View 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';

View 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;
}

View 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));
}
}

View File

@@ -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);
}
}

View 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();
});
});

View 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();
}
}
}

View 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();
});
});
});

View 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);
}
}

View 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',
};

View File

@@ -0,0 +1,3 @@
# Bootstrap
The services in this folder should be singletons and used for sharing data and functionality.

View 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);
}
});
}
}

View 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';
}
}
}
}

View 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 });
});
});

View 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;
}
}

View 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 });
}
}

View 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([]);
});
});

View 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 });
}
}

View 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()
);
});
});
}
}

View 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');
}
}

View 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';

View 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,
},
];

View File

@@ -0,0 +1,3 @@
# Interceptors
https://angular.io/guide/http#intercepting-requests-and-responses

View File

@@ -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 });
});
});

View 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('/');
}
}

View 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);
}
}

View File

@@ -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');
});
});

View 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);
}
}

View 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 },
];

View 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);
})
);
}
}

View 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);
}
}

View File

@@ -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 });
});
});

View 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);
}
}

View File

@@ -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');
});
});

View 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()),
})
);
}
}

View 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');
});
});

View 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);
}
}

View 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.`
);
}
}

View 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',
};

View 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));
}
}

View 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],
};
}
}

View 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();
}
}
}

View 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 });
}
}

View 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 {}

View 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 {}

View 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 {}

View File

@@ -0,0 +1 @@
<page-header></page-header>

View 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() {}
}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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 {}

View 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>

View 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;
}
);
}
}

View File

@@ -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>

View File

@@ -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;
}
};
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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');
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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 = '';
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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) {}
}

View 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();
});
});

View 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);
}
}

View 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';

View 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);
}
}

View 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);
}
}

View 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();
}
}

View 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 = [];
}
}

View 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,
});
}
}

View 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 = {};
}
}

View 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 {}

View 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',
},
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View 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;
}
}

View 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