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

84
front/app/.eslintrc.json Normal file
View File

@@ -0,0 +1,84 @@
{
"root": true,
"ignorePatterns": ["dist"],
"overrides": [
{
"files": ["*.ts"],
"parserOptions": {
"project": ["tsconfig.json", "e2e/tsconfig.json"],
"createDefaultProgram": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"dot-notation": ["warn"],
"max-len": [
"warn",
{
"code": 100,
"ignoreComments": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true
}
],
"object-shorthand": [
"warn",
"always",
{
"avoidQuotes": true
}
],
"quote-props": ["warn", "consistent-as-needed"],
"quotes": [
"warn",
"single",
{
"allowTemplateLiterals": true
}
],
"semi": ["warn", "always"],
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": "off",
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"style": "kebab-case"
}
],
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"style": "camelCase"
}
],
"@angular-eslint/no-empty-lifecycle-method": "off"
}
},
{
"files": ["*.html"],
"extends": ["plugin:@angular-eslint/template/recommended"],
"rules": {}
},
{
"files": ["*.js"],
"parserOptions": {
"ecmaVersion": 11
},
"env": {
"node": true,
"amd": true
},
"extends": ["eslint:recommended"],
"rules": {}
}
]
}

View File

@@ -0,0 +1,3 @@
# add files you wish to ignore here
dist/
node_modules/

17
front/app/.prettierrc Normal file
View File

@@ -0,0 +1,17 @@
{
"arrowParens": "avoid",
"bracketSpacing": true,
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 100,
"proseWrap": "preserve",
"quoteProps": "consistent",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false
}

39
front/app/.stylelintrc Normal file
View File

@@ -0,0 +1,39 @@
{
"ignoreFiles": [
"dist/**",
"schematics/**"
],
"extends": [
"stylelint-config-standard",
"stylelint-config-recommended-scss",
"stylelint-config-rational-order"
],
"plugins": [
"stylelint-order"
],
"rules": {
"alpha-value-notation": null,
"annotation-no-unknown": null,
"at-rule-empty-line-before": null,
"color-function-notation": "legacy",
"function-no-unknown": null,
"no-descending-specificity": null,
"no-empty-source": null,
"number-leading-zero": "never",
"selector-pseudo-element-no-unknown": [
true,
{
"ignorePseudoElements": [
"ng-deep"
]
}
],
"selector-class-pattern": null,
"selector-type-no-unknown": null,
"string-quotes": "single",
"value-keyword-case": null,
"scss/at-extend-no-missing-placeholder": null,
"scss/comment-no-empty": null,
"scss/operator-no-unspaced": null
}
}

View File

@@ -1,4 +1,17 @@
{ {
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 // See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
"recommendations": ["angular.ng-template"] // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
}
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
"angular.ng-template",
"cyrilletuzi.angular-schematics",
"apk27.ngx-translate-lookup",
"esbenp.prettier-vscode",
"stylelint.vscode-stylelint",
"syler.sass-indented",
"editorconfig.editorconfig",
"box-of-hats.quick-material-icons",
"mrmlnc.vscode-scss"
]
}

20
front/app/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"editor.rulers": [100],
"html.format.wrapLineLength": 100,
"html.format.wrapAttributes": "preserve-aligned",
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.fixAll.stylelint": true
},
"ngx-translate.lookup.resourcesType": "json",
"ngx-translate.lookup.resourcesPath": "src/assets/i18n/zh-CN.json",
}

21
front/app/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Zongbin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -25,7 +25,7 @@
"src/assets" "src/assets"
], ],
"styles": [ "styles": [
"@angular/material/prebuilt-themes/deeppurple-amber.css", "src/styles.scss",
"src/styles.css" "src/styles.css"
], ],
"scripts": [] "scripts": []
@@ -36,12 +36,12 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kb", "maximumWarning": "500kb",
"maximumError": "1mb" "maximumError": "4mb"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "2kb", "maximumWarning": "2kb",
"maximumError": "4kb" "maximumError": "16kb"
} }
], ],
"outputHashing": "all" "outputHashing": "all"
@@ -65,6 +65,9 @@
}, },
"development": { "development": {
"browserTarget": "app:build:development" "browserTarget": "app:build:development"
},
"options": {
"proxyConfig": "proxy.config.js"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
@@ -88,11 +91,20 @@
"src/assets" "src/assets"
], ],
"styles": [ "styles": [
"@angular/material/prebuilt-themes/deeppurple-amber.css", "src/styles.scss",
"src/styles.css" "src/styles.css"
], ],
"scripts": [] "scripts": []
} }
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -6,35 +6,70 @@
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"build:prod": "ng build --prod",
"lint": "npm run lint:ts && npm run lint:scss",
"lint:ts": "eslint \"src/**/*.ts\" --fix",
"lint:scss": "stylelint \"src/**/*.scss\" --fix",
"hmr": "ng serve --hmr --disable-host-check"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^15.0.0", "@angular/animations": "^15.0.0",
"@angular/cdk": "^15.0.4", "@angular/cdk": "~15.0.3",
"@angular/common": "^15.0.0", "@angular/common": "^15.0.0",
"@angular/compiler": "^15.0.0", "@angular/compiler": "^15.0.0",
"@angular/core": "^15.0.0", "@angular/core": "^15.0.0",
"@angular/forms": "^15.0.0", "@angular/forms": "^15.0.0",
"@angular/material": "^15.0.4", "@angular/material": "~15.0.3",
"@angular/material-moment-adapter": "~15.0.3",
"@angular/platform-browser": "^15.0.0", "@angular/platform-browser": "^15.0.0",
"@angular/platform-browser-dynamic": "^15.0.0", "@angular/platform-browser-dynamic": "^15.0.0",
"@angular/router": "^15.0.0", "@angular/router": "^15.0.0",
"@ng-matero/extensions": "^15.0.1",
"@ng-matero/extensions-moment-adapter": "^15.0.0",
"@ngx-formly/core": "^6.0.4",
"@ngx-formly/material": "^6.0.4",
"@ngx-translate/core": "^14.0.0",
"@ngx-translate/http-loader": "^7.0.0",
"moment": "^2.29.4",
"ng-matero": "^15.1.1",
"ngx-permissions": "^14.0.0",
"ngx-progressbar": "^9.0.0",
"ngx-toastr": "^16.0.1",
"photoviewer": "^3.6.6",
"rxjs": "~7.5.0", "rxjs": "~7.5.0",
"screenfull": "^6.0.2",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.12.0" "zone.js": "~0.12.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^15.0.5", "@angular-devkit/build-angular": "^15.0.5",
"@angular-eslint/builder": "^15.1.0",
"@angular-eslint/eslint-plugin": "^15.1.0",
"@angular-eslint/eslint-plugin-template": "^15.1.0",
"@angular-eslint/schematics": "^15.1.0",
"@angular-eslint/template-parser": "^15.1.0",
"@angular/cli": "~15.0.5", "@angular/cli": "~15.0.5",
"@angular/compiler-cli": "^15.0.0", "@angular/compiler-cli": "^15.0.0",
"@types/jasmine": "~4.3.0", "@types/jasmine": "~4.3.0",
"@typescript-eslint/eslint-plugin": "^5.46.0",
"@typescript-eslint/parser": "^5.46.0",
"eslint": "^8.30.0",
"jasmine-core": "~4.5.0", "jasmine-core": "~4.5.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.1.0", "karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.0.0", "karma-jasmine-html-reporter": "~2.0.0",
"parse5": "^7.1.2",
"prettier": "^2.8.1",
"stylelint": "^14.16.0",
"stylelint-config-rational-order": "^0.1.2",
"stylelint-config-recommended-scss": "^8.0.0",
"stylelint-config-standard": "^29.0.0",
"stylelint-order": "^5.0.0",
"stylelint-scss": "^4.3.0",
"typescript": "~4.8.2" "typescript": "~4.8.2"
} }
} }

22
front/app/proxy.config.js Normal file
View File

@@ -0,0 +1,22 @@
// https://angular.io/guide/build#proxying-to-a-backend-server
const PROXY_CONFIG = {
'/users/**': {
target: 'https://api.github.com',
changeOrigin: true,
secure: false,
logLevel: 'debug',
// onProxyReq: (proxyReq, req, res) => {
// const cookieMap = {
// SID: '',
// };
// let cookie = '';
// for (const key of Object.keys(cookieMap)) {
// cookie += `${key}=${cookieMap[key]}; `;
// }
// proxyReq.setHeader('cookie', cookie);
// },
},
};
module.exports = PROXY_CONFIG;

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({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', template: '<router-outlet></router-outlet>',
styleUrls: ['./app.component.css']
}) })
export class AppComponent { export class AppComponent implements OnInit, AfterViewInit {
title = 'app'; constructor(private preloader: PreloaderService) {}
ngOnInit() {}
ngAfterViewInit() {
this.preloader.hide();
}
} }

View File

@@ -1,23 +1,58 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; 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'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({ @NgModule({
declarations: [ declarations: [AppComponent],
AppComponent,
AuthComponent
],
imports: [ imports: [
BrowserModule, BrowserModule,
AppRoutingModule, HttpClientModule,
BrowserAnimationsModule CoreModule,
ThemeModule,
RoutesModule,
SharedModule,
FormlyConfigModule.forRoot(),
NgxPermissionsModule.forRoot(),
ToastrModule.forRoot(),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: TranslateHttpLoaderFactory,
deps: [HttpClient],
},
}),
BrowserAnimationsModule,
], ],
providers: [], providers: [
bootstrap: [AppComponent] { 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);
}
}

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