Installing ng-matero
This commit is contained in:
84
front/app/.eslintrc.json
Normal file
84
front/app/.eslintrc.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
3
front/app/.prettierignore
Normal file
3
front/app/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# add files you wish to ignore here
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
17
front/app/.prettierrc
Normal file
17
front/app/.prettierrc
Normal 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
39
front/app/.stylelintrc
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
front/app/.vscode/extensions.json
vendored
21
front/app/.vscode/extensions.json
vendored
@@ -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
20
front/app/.vscode/settings.json
vendored
Normal 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
21
front/app/LICENSE
Normal 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.
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6621
front/app/package-lock.json
generated
6621
front/app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
22
front/app/proxy.config.js
Normal 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;
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { NgModule } from '@angular/core';
|
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
|
||||||
|
|
||||||
const routes: Routes = [];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [RouterModule.forRoot(routes)],
|
|
||||||
exports: [RouterModule]
|
|
||||||
})
|
|
||||||
export class AppRoutingModule { }
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<app-auth></app-auth>
|
|
||||||
|
|
||||||
<router-outlet></router-outlet>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
|
||||||
import { AppComponent } from './app.component';
|
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [
|
|
||||||
RouterTestingModule
|
|
||||||
],
|
|
||||||
declarations: [
|
|
||||||
AppComponent
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create the app', () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should have as title 'app'`, () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app.title).toEqual('app');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render title', () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
fixture.detectChanges();
|
|
||||||
const compiled = fixture.nativeElement as HTMLElement;
|
|
||||||
expect(compiled.querySelector('.content span')?.textContent).toContain('app app is running!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, OnInit, AfterViewInit } from '@angular/core';
|
||||||
|
import { PreloaderService } from '@core';
|
||||||
|
|
||||||
@Component({
|
@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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<p>auth works!</p>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { AuthComponent } from './auth.component';
|
|
||||||
|
|
||||||
describe('AuthComponent', () => {
|
|
||||||
let component: AuthComponent;
|
|
||||||
let fixture: ComponentFixture<AuthComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [ AuthComponent ]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(AuthComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-auth',
|
|
||||||
templateUrl: './auth.component.html',
|
|
||||||
styleUrls: ['./auth.component.css']
|
|
||||||
})
|
|
||||||
export class AuthComponent {
|
|
||||||
|
|
||||||
}
|
|
||||||
17
front/app/src/app/core/authentication/README.md
Normal file
17
front/app/src/app/core/authentication/README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Authentication
|
||||||
|
|
||||||
|
1. Modify the token key at `token.service` to another name such as `TOKEN` or `your-app-token`. By default set to `ng-matero-token`.
|
||||||
|
|
||||||
|
2. Replace the APIs at `login.service` with your owns.
|
||||||
|
|
||||||
|
- `/auth/login` Login
|
||||||
|
- `/auth/refresh` Refresh
|
||||||
|
- `/auth/logout` Logout
|
||||||
|
- `/me` Get user information
|
||||||
|
- `/me/menu` Get user menu
|
||||||
|
|
||||||
|
3. If you have modified the login url (defaults to `auth/login`), you should correct it in the following files.
|
||||||
|
|
||||||
|
- `auth.guard.ts`
|
||||||
|
- `error-interceptor.ts`
|
||||||
|
- `token-interceptor.ts`
|
||||||
56
front/app/src/app/core/authentication/auth.guard.spec.ts
Normal file
56
front/app/src/app/core/authentication/auth.guard.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||||
|
import { TokenService, AuthGuard, AuthService } from '@core/authentication';
|
||||||
|
|
||||||
|
@Component({ template: '' })
|
||||||
|
class DummyComponent {}
|
||||||
|
|
||||||
|
describe('AuthGuard', () => {
|
||||||
|
const route: any = {};
|
||||||
|
const state: any = {};
|
||||||
|
let router: Router;
|
||||||
|
let authGuard: AuthGuard;
|
||||||
|
let authService: AuthService;
|
||||||
|
let tokenService: TokenService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
HttpClientTestingModule,
|
||||||
|
RouterTestingModule.withRoutes([
|
||||||
|
{ path: 'dashboard', component: DummyComponent, canActivate: [AuthGuard] },
|
||||||
|
{ path: 'auth/login', component: DummyComponent },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
declarations: [DummyComponent],
|
||||||
|
providers: [{ provide: LocalStorageService, useClass: MemoryStorageService }],
|
||||||
|
});
|
||||||
|
TestBed.createComponent(DummyComponent);
|
||||||
|
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
authGuard = TestBed.inject(AuthGuard);
|
||||||
|
authService = TestBed.inject(AuthService);
|
||||||
|
tokenService = TestBed.inject(TokenService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(authGuard).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be authenticated', () => {
|
||||||
|
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||||
|
|
||||||
|
expect(authGuard.canActivate(route, state)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redirect to /auth/login when authenticate failed', () => {
|
||||||
|
spyOn(authService, 'check').and.returnValue(false);
|
||||||
|
|
||||||
|
expect(authGuard.canActivate(route, state)).toEqual(router.parseUrl('/auth/login'));
|
||||||
|
});
|
||||||
|
});
|
||||||
32
front/app/src/app/core/authentication/auth.guard.ts
Normal file
32
front/app/src/app/core/authentication/auth.guard.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
ActivatedRouteSnapshot,
|
||||||
|
CanActivate,
|
||||||
|
CanActivateChild,
|
||||||
|
Router,
|
||||||
|
RouterStateSnapshot,
|
||||||
|
UrlTree,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AuthGuard implements CanActivate, CanActivateChild {
|
||||||
|
constructor(private auth: AuthService, private router: Router) {}
|
||||||
|
|
||||||
|
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
|
||||||
|
return this.authenticate();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivateChild(
|
||||||
|
childRoute: ActivatedRouteSnapshot,
|
||||||
|
state: RouterStateSnapshot
|
||||||
|
): boolean | UrlTree {
|
||||||
|
return this.authenticate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private authenticate(): boolean | UrlTree {
|
||||||
|
return this.auth.check() ? true : this.router.parseUrl('/auth/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
133
front/app/src/app/core/authentication/auth.service.spec.ts
Normal file
133
front/app/src/app/core/authentication/auth.service.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { skip } from 'rxjs/operators';
|
||||||
|
import { HttpRequest } from '@angular/common/http';
|
||||||
|
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||||
|
import { AuthService, LoginService, TokenService, User } from '@core/authentication';
|
||||||
|
|
||||||
|
describe('AuthService', () => {
|
||||||
|
let authService: AuthService;
|
||||||
|
let loginService: LoginService;
|
||||||
|
let tokenService: TokenService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let user$: Observable<User>;
|
||||||
|
const email = 'foo@bar.com';
|
||||||
|
const token = { access_token: 'token', token_type: 'bearer' };
|
||||||
|
const user = { id: 1, email };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [{ provide: LocalStorageService, useClass: MemoryStorageService }],
|
||||||
|
});
|
||||||
|
loginService = TestBed.inject(LoginService);
|
||||||
|
authService = TestBed.inject(AuthService);
|
||||||
|
tokenService = TestBed.inject(TokenService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
user$ = authService.user();
|
||||||
|
authService.change().subscribe(user => {
|
||||||
|
expect(user).toBeInstanceOf(Object);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(authService).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log in failed', () => {
|
||||||
|
authService.login(email, 'password', false).subscribe(isLogin => expect(isLogin).toBeFalse());
|
||||||
|
httpMock.expectOne('/auth/login').flush({});
|
||||||
|
|
||||||
|
expect(authService.check()).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log in successful and get user info', () => {
|
||||||
|
user$.pipe(skip(1)).subscribe(currentUser => expect(currentUser.id).toEqual(user.id));
|
||||||
|
authService.login(email, 'password', false).subscribe(isLogin => expect(isLogin).toBeTrue());
|
||||||
|
httpMock.expectOne('/auth/login').flush(token);
|
||||||
|
|
||||||
|
expect(authService.check()).toBeTrue();
|
||||||
|
httpMock.expectOne('/me').flush(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log out failed when user is not login', () => {
|
||||||
|
spyOn(loginService, 'logout').and.callThrough();
|
||||||
|
expect(authService.check()).toBeFalse();
|
||||||
|
|
||||||
|
authService.logout().subscribe();
|
||||||
|
httpMock.expectOne('/auth/logout');
|
||||||
|
|
||||||
|
expect(authService.check()).toBeFalse();
|
||||||
|
expect(loginService.logout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log out successful when user is login', () => {
|
||||||
|
tokenService.set(token);
|
||||||
|
expect(authService.check()).toBeTrue();
|
||||||
|
httpMock.expectOne('/me').flush(user);
|
||||||
|
|
||||||
|
user$.pipe(skip(1)).subscribe(currentUser => expect(currentUser.id).toBeUndefined());
|
||||||
|
authService.logout().subscribe();
|
||||||
|
httpMock.expectOne('/auth/logout').flush({});
|
||||||
|
|
||||||
|
expect(authService.check()).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should refresh token when access_token is valid', fakeAsync(() => {
|
||||||
|
tokenService.set(Object.assign({ expires_in: 5 }, token));
|
||||||
|
expect(authService.check()).toBeTrue();
|
||||||
|
httpMock.expectOne('/me').flush(user);
|
||||||
|
const match = (req: HttpRequest<any>) => req.url === '/auth/refresh' && !req.body.refresh_token;
|
||||||
|
|
||||||
|
tick(4000);
|
||||||
|
expect(authService.check()).toBeTrue();
|
||||||
|
httpMock.match(match)[0].flush(token);
|
||||||
|
|
||||||
|
expect(authService.check()).toBeTrue();
|
||||||
|
httpMock.expectNone('/me');
|
||||||
|
tokenService.ngOnDestroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should refresh token when access_token is invalid and refresh_token is valid', fakeAsync(() => {
|
||||||
|
tokenService.set(Object.assign({ expires_in: 5, refresh_token: 'foo' }, token));
|
||||||
|
const match = (req: HttpRequest<any>) =>
|
||||||
|
req.url === '/auth/refresh' && req.body.refresh_token === 'foo';
|
||||||
|
|
||||||
|
expect(authService.check()).toBeTrue();
|
||||||
|
httpMock.expectOne('/me').flush(user);
|
||||||
|
tick(10000);
|
||||||
|
expect(authService.check()).toBeFalse();
|
||||||
|
httpMock.match(match)[0].flush(token);
|
||||||
|
|
||||||
|
expect(authService.check()).toBeTrue();
|
||||||
|
httpMock.expectNone('/me');
|
||||||
|
tokenService.ngOnDestroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('it should clear token when access_token is invalid and refresh token response is 401', fakeAsync(() => {
|
||||||
|
spyOn(tokenService, 'set').and.callThrough();
|
||||||
|
tokenService.set(Object.assign({ expires_in: 5, refresh_token: 'foo' }, token));
|
||||||
|
const match = (req: HttpRequest<any>) =>
|
||||||
|
req.url === '/auth/refresh' && req.body.refresh_token === 'foo';
|
||||||
|
|
||||||
|
tick(10000);
|
||||||
|
expect(authService.check()).toBeFalse();
|
||||||
|
httpMock.expectOne('/me').flush({});
|
||||||
|
httpMock.match(match)[0].flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
|
expect(authService.check()).toBeFalse();
|
||||||
|
expect(tokenService.set).toHaveBeenCalledWith(undefined);
|
||||||
|
tokenService.ngOnDestroy();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('it only call http request once when on change subscribe twice', () => {
|
||||||
|
authService.change().subscribe();
|
||||||
|
tokenService.set(token);
|
||||||
|
httpMock.expectOne('/me').flush({});
|
||||||
|
});
|
||||||
|
});
|
||||||
79
front/app/src/app/core/authentication/auth.service.ts
Normal file
79
front/app/src/app/core/authentication/auth.service.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, iif, merge, of } from 'rxjs';
|
||||||
|
import { catchError, map, share, switchMap, tap } from 'rxjs/operators';
|
||||||
|
import { TokenService } from './token.service';
|
||||||
|
import { LoginService } from './login.service';
|
||||||
|
import { filterObject, isEmptyObject } from './helpers';
|
||||||
|
import { User } from './interface';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AuthService {
|
||||||
|
private user$ = new BehaviorSubject<User>({});
|
||||||
|
private change$ = merge(
|
||||||
|
this.tokenService.change(),
|
||||||
|
this.tokenService.refresh().pipe(switchMap(() => this.refresh()))
|
||||||
|
).pipe(
|
||||||
|
switchMap(() => this.assignUser()),
|
||||||
|
share()
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(private loginService: LoginService, private tokenService: TokenService) {}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
return new Promise<void>(resolve => this.change$.subscribe(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
change() {
|
||||||
|
return this.change$;
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
return this.tokenService.valid();
|
||||||
|
}
|
||||||
|
|
||||||
|
login(username: string, password: string, rememberMe = false) {
|
||||||
|
return this.loginService.login(username, password, rememberMe).pipe(
|
||||||
|
tap(token => this.tokenService.set(token)),
|
||||||
|
map(() => this.check())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
return this.loginService
|
||||||
|
.refresh(filterObject({ refresh_token: this.tokenService.getRefreshToken() }))
|
||||||
|
.pipe(
|
||||||
|
catchError(() => of(undefined)),
|
||||||
|
tap(token => this.tokenService.set(token)),
|
||||||
|
map(() => this.check())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
return this.loginService.logout().pipe(
|
||||||
|
tap(() => this.tokenService.clear()),
|
||||||
|
map(() => !this.check())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
user() {
|
||||||
|
return this.user$.pipe(share());
|
||||||
|
}
|
||||||
|
|
||||||
|
menu() {
|
||||||
|
return iif(() => this.check(), this.loginService.menu(), of([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private assignUser() {
|
||||||
|
if (!this.check()) {
|
||||||
|
return of({}).pipe(tap(user => this.user$.next(user)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmptyObject(this.user$.getValue())) {
|
||||||
|
return of(this.user$.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.loginService.me().pipe(tap(user => this.user$.next(user)));
|
||||||
|
}
|
||||||
|
}
|
||||||
57
front/app/src/app/core/authentication/helpers.ts
Normal file
57
front/app/src/app/core/authentication/helpers.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { fromByteArray, toByteArray } from 'base64-js';
|
||||||
|
|
||||||
|
export class Base64 {
|
||||||
|
static encode(plainText: string): string {
|
||||||
|
return fromByteArray(pack(plainText)).replace(/[+/=]/g, m => {
|
||||||
|
return { '+': '-', '/': '_', '=': '' }[m] as string;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static decode(b64: string): string {
|
||||||
|
b64 = b64.replace(/[-_]/g, m => {
|
||||||
|
return { '-': '+', '_': '/' }[m] as string;
|
||||||
|
});
|
||||||
|
while (b64.length % 4) {
|
||||||
|
b64 += '=';
|
||||||
|
}
|
||||||
|
|
||||||
|
return unpack(toByteArray(b64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pack(str: string) {
|
||||||
|
const bytes: any = [];
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
bytes.push(...[str.charCodeAt(i)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unpack(byteArray: any) {
|
||||||
|
return String.fromCharCode(...byteArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const base64 = { encode: Base64.encode, decode: Base64.decode };
|
||||||
|
|
||||||
|
export function capitalize(text: string): string {
|
||||||
|
return text.substring(0, 1).toUpperCase() + text.substring(1, text.length).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentTimestamp(): number {
|
||||||
|
return Math.ceil(new Date().getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeLeft(expiredAt: number): number {
|
||||||
|
return Math.max(0, expiredAt - currentTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterObject<T extends Record<string, unknown>>(obj: T) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj).filter(([, value]) => value !== undefined && value !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmptyObject(obj: Record<string, any>) {
|
||||||
|
return Object.keys(obj).length === 0;
|
||||||
|
}
|
||||||
9
front/app/src/app/core/authentication/index.ts
Normal file
9
front/app/src/app/core/authentication/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './interface';
|
||||||
|
export * from './auth.guard';
|
||||||
|
export * from './auth.service';
|
||||||
|
export * from './token-factory.service';
|
||||||
|
export * from './token.service';
|
||||||
|
export * from './token';
|
||||||
|
export * from './login.service';
|
||||||
|
export * from './user';
|
||||||
|
export * from './helpers';
|
||||||
20
front/app/src/app/core/authentication/interface.ts
Normal file
20
front/app/src/app/core/authentication/interface.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export interface User {
|
||||||
|
[prop: string]: any;
|
||||||
|
|
||||||
|
id?: number | string | null;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
roles?: any[];
|
||||||
|
permissions?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Token {
|
||||||
|
[prop: string]: any;
|
||||||
|
|
||||||
|
access_token: string;
|
||||||
|
token_type?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
exp?: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
}
|
||||||
32
front/app/src/app/core/authentication/login.service.ts
Normal file
32
front/app/src/app/core/authentication/login.service.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Token, User } from './interface';
|
||||||
|
import { Menu } from '@core';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class LoginService {
|
||||||
|
constructor(protected http: HttpClient) {}
|
||||||
|
|
||||||
|
login(username: string, password: string, rememberMe = false) {
|
||||||
|
return this.http.post<Token>('/auth/login', { username, password, rememberMe });
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(params: Record<string, any>) {
|
||||||
|
return this.http.post<Token>('/auth/refresh', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
return this.http.post<any>('/auth/logout', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
me() {
|
||||||
|
return this.http.get<User>('/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
menu() {
|
||||||
|
return this.http.get<{ menu: Menu[] }>('/me/menu').pipe(map(res => res.menu));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Token } from './interface';
|
||||||
|
import { SimpleToken, JwtToken, BaseToken } from './token';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class TokenFactory {
|
||||||
|
create(attributes: Token): BaseToken | undefined {
|
||||||
|
if (!attributes.access_token) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JwtToken.is(attributes.access_token)) {
|
||||||
|
return new JwtToken(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SimpleToken(attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
front/app/src/app/core/authentication/token.service.spec.ts
Normal file
52
front/app/src/app/core/authentication/token.service.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
import { MemoryStorageService, LocalStorageService } from '@shared/services/storage.service';
|
||||||
|
import { TokenService, currentTimestamp, TokenFactory, SimpleToken } from '@core/authentication';
|
||||||
|
|
||||||
|
describe('TokenService', () => {
|
||||||
|
let tokenService: TokenService;
|
||||||
|
let tokenFactory: TokenFactory;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [{ provide: LocalStorageService, useClass: MemoryStorageService }],
|
||||||
|
});
|
||||||
|
tokenService = TestBed.inject(TokenService);
|
||||||
|
tokenFactory = TestBed.inject(TokenFactory);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(tokenService).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get authorization header value', () => {
|
||||||
|
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||||
|
|
||||||
|
expect(tokenService.getBearerToken()).toEqual('Bearer token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cannot get authorization header value', () => {
|
||||||
|
tokenService.set({ access_token: '', token_type: 'bearer' });
|
||||||
|
|
||||||
|
expect(tokenService.getBearerToken()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not has exp when token has expires_in', () => {
|
||||||
|
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||||
|
|
||||||
|
tokenService
|
||||||
|
.change()
|
||||||
|
.pipe(tap(token => expect(token!.exp).toBeUndefined()))
|
||||||
|
.subscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should has exp when token has expires_in', () => {
|
||||||
|
const expiresIn = 3600;
|
||||||
|
tokenService.set({ access_token: 'token', token_type: 'bearer', expires_in: expiresIn });
|
||||||
|
|
||||||
|
tokenService
|
||||||
|
.change()
|
||||||
|
.pipe(tap(token => expect(token!.exp).toEqual(currentTimestamp() + expiresIn)))
|
||||||
|
.subscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
99
front/app/src/app/core/authentication/token.service.ts
Normal file
99
front/app/src/app/core/authentication/token.service.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Injectable, OnDestroy } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable, Subject, Subscription, timer } from 'rxjs';
|
||||||
|
import { share } from 'rxjs/operators';
|
||||||
|
import { LocalStorageService } from '@shared';
|
||||||
|
import { Token } from './interface';
|
||||||
|
import { BaseToken } from './token';
|
||||||
|
import { TokenFactory } from './token-factory.service';
|
||||||
|
import { currentTimestamp, filterObject } from './helpers';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class TokenService implements OnDestroy {
|
||||||
|
private key = 'ng-matero-token';
|
||||||
|
|
||||||
|
private change$ = new BehaviorSubject<BaseToken | undefined>(undefined);
|
||||||
|
private refresh$ = new Subject<BaseToken | undefined>();
|
||||||
|
private timer$?: Subscription;
|
||||||
|
|
||||||
|
private _token?: BaseToken;
|
||||||
|
|
||||||
|
constructor(private store: LocalStorageService, private factory: TokenFactory) {}
|
||||||
|
|
||||||
|
private get token(): BaseToken | undefined {
|
||||||
|
if (!this._token) {
|
||||||
|
this._token = this.factory.create(this.store.get(this.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._token;
|
||||||
|
}
|
||||||
|
|
||||||
|
change(): Observable<BaseToken | undefined> {
|
||||||
|
return this.change$.pipe(share());
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(): Observable<BaseToken | undefined> {
|
||||||
|
this.buildRefresh();
|
||||||
|
|
||||||
|
return this.refresh$.pipe(share());
|
||||||
|
}
|
||||||
|
|
||||||
|
set(token?: Token): TokenService {
|
||||||
|
this.save(token);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
valid(): boolean {
|
||||||
|
return this.token?.valid() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBearerToken(): string {
|
||||||
|
return this.token?.getBearerToken() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getRefreshToken(): string | void {
|
||||||
|
return this.token?.refresh_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.clearRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(token?: Token): void {
|
||||||
|
this._token = undefined;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
this.store.remove(this.key);
|
||||||
|
} else {
|
||||||
|
const value = Object.assign({ access_token: '', token_type: 'Bearer' }, token, {
|
||||||
|
exp: token.expires_in ? currentTimestamp() + token.expires_in : null,
|
||||||
|
});
|
||||||
|
this.store.set(this.key, filterObject(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.change$.next(this.token);
|
||||||
|
this.buildRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRefresh() {
|
||||||
|
this.clearRefresh();
|
||||||
|
|
||||||
|
if (this.token?.needRefresh()) {
|
||||||
|
this.timer$ = timer(this.token.getRefreshTime() * 1000).subscribe(() => {
|
||||||
|
this.refresh$.next(this.token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearRefresh() {
|
||||||
|
if (this.timer$ && !this.timer$.closed) {
|
||||||
|
this.timer$.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
front/app/src/app/core/authentication/token.spec.ts
Normal file
41
front/app/src/app/core/authentication/token.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { base64, currentTimestamp, JwtToken } from '@core/authentication';
|
||||||
|
|
||||||
|
describe('Token', () => {
|
||||||
|
describe('JwtToken', () => {
|
||||||
|
function generateToken(params: any, typ = 'JWT') {
|
||||||
|
return [
|
||||||
|
base64.encode(JSON.stringify({ typ, alg: 'HS256' })),
|
||||||
|
base64.encode(JSON.stringify(params)),
|
||||||
|
base64.encode('ng-matero'),
|
||||||
|
].join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const exp = currentTimestamp() + 3600;
|
||||||
|
const token = new JwtToken({
|
||||||
|
access_token: generateToken({ exp }, 'at+JWT'),
|
||||||
|
token_type: 'Bearer',
|
||||||
|
});
|
||||||
|
|
||||||
|
it('test access_token is JWT', () => {
|
||||||
|
expect(JwtToken.is(token.access_token)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('test bearer token', function () {
|
||||||
|
expect(token.getBearerToken()).toBe(`Bearer ${token.access_token}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('test payload has exp attribute', () => {
|
||||||
|
expect(token.exp).toEqual(exp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('test payload does not has exp attribute', () => {
|
||||||
|
expect(token.exp).toEqual(exp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('test does not has exp attribute', () => {
|
||||||
|
const token = new JwtToken({ access_token: generateToken({}), token_type: 'Bearer' });
|
||||||
|
|
||||||
|
expect(token.exp).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
87
front/app/src/app/core/authentication/token.ts
Normal file
87
front/app/src/app/core/authentication/token.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { base64, capitalize, currentTimestamp, timeLeft } from './helpers';
|
||||||
|
import { Token } from './interface';
|
||||||
|
|
||||||
|
export abstract class BaseToken {
|
||||||
|
constructor(protected attributes: Token) {}
|
||||||
|
|
||||||
|
get access_token(): string {
|
||||||
|
return this.attributes.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
get refresh_token(): string | void {
|
||||||
|
return this.attributes.refresh_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
get token_type(): string {
|
||||||
|
return this.attributes.token_type ?? 'bearer';
|
||||||
|
}
|
||||||
|
|
||||||
|
get exp(): number | void {
|
||||||
|
return this.attributes.exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
valid(): boolean {
|
||||||
|
return this.hasAccessToken() && !this.isExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
getBearerToken(): string {
|
||||||
|
return this.access_token
|
||||||
|
? [capitalize(this.token_type), this.access_token].join(' ').trim()
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
needRefresh(): boolean {
|
||||||
|
return this.exp !== undefined && this.exp >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRefreshTime(): number {
|
||||||
|
return timeLeft((this.exp ?? 0) - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasAccessToken(): boolean {
|
||||||
|
return !!this.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isExpired(): boolean {
|
||||||
|
return this.exp !== undefined && this.exp - currentTimestamp() <= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SimpleToken extends BaseToken {}
|
||||||
|
|
||||||
|
export class JwtToken extends SimpleToken {
|
||||||
|
private _payload?: { exp?: number | void };
|
||||||
|
|
||||||
|
static is(accessToken: string): boolean {
|
||||||
|
try {
|
||||||
|
const [_header] = accessToken.split('.');
|
||||||
|
const header = JSON.parse(base64.decode(_header));
|
||||||
|
|
||||||
|
return header.typ.toUpperCase().includes('JWT');
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get exp(): number | void {
|
||||||
|
return this.payload?.exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get payload(): { exp?: number | void } {
|
||||||
|
if (!this.access_token) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._payload) {
|
||||||
|
return this._payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, payload] = this.access_token.split('.');
|
||||||
|
const data = JSON.parse(base64.decode(payload));
|
||||||
|
if (!data.exp) {
|
||||||
|
data.exp = this.attributes.exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (this._payload = data);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
front/app/src/app/core/authentication/user.ts
Normal file
14
front/app/src/app/core/authentication/user.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { User } from './interface';
|
||||||
|
|
||||||
|
export const admin: User = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Zongbin',
|
||||||
|
email: 'nzb329@163.com',
|
||||||
|
avatar: './assets/images/avatar.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guest: User = {
|
||||||
|
name: 'unknown',
|
||||||
|
email: 'unknown',
|
||||||
|
avatar: './assets/images/avatar-default.jpg',
|
||||||
|
};
|
||||||
3
front/app/src/app/core/bootstrap/README.md
Normal file
3
front/app/src/app/core/bootstrap/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Bootstrap
|
||||||
|
|
||||||
|
The services in this folder should be singletons and used for sharing data and functionality.
|
||||||
150
front/app/src/app/core/bootstrap/menu.service.ts
Normal file
150
front/app/src/app/core/bootstrap/menu.service.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
import { share } from 'rxjs/operators';
|
||||||
|
|
||||||
|
export interface MenuTag {
|
||||||
|
color: string; // background color
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuPermissions {
|
||||||
|
only?: string | string[];
|
||||||
|
except?: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuChildrenItem {
|
||||||
|
route: string;
|
||||||
|
name: string;
|
||||||
|
type: 'link' | 'sub' | 'extLink' | 'extTabLink';
|
||||||
|
children?: MenuChildrenItem[];
|
||||||
|
permissions?: MenuPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Menu {
|
||||||
|
route: string;
|
||||||
|
name: string;
|
||||||
|
type: 'link' | 'sub' | 'extLink' | 'extTabLink';
|
||||||
|
icon: string;
|
||||||
|
label?: MenuTag;
|
||||||
|
badge?: MenuTag;
|
||||||
|
children?: MenuChildrenItem[];
|
||||||
|
permissions?: MenuPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class MenuService {
|
||||||
|
private menu$: BehaviorSubject<Menu[]> = new BehaviorSubject<Menu[]>([]);
|
||||||
|
|
||||||
|
/** Get all the menu data. */
|
||||||
|
getAll(): Observable<Menu[]> {
|
||||||
|
return this.menu$.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Observe the change of menu data. */
|
||||||
|
change(): Observable<Menu[]> {
|
||||||
|
return this.menu$.pipe(share());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialize the menu data. */
|
||||||
|
set(menu: Menu[]): Observable<Menu[]> {
|
||||||
|
this.menu$.next(menu);
|
||||||
|
return this.menu$.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add one item to the menu data. */
|
||||||
|
add(menu: Menu) {
|
||||||
|
const tmpMenu = this.menu$.value;
|
||||||
|
tmpMenu.push(menu);
|
||||||
|
this.menu$.next(tmpMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the menu data. */
|
||||||
|
reset() {
|
||||||
|
this.menu$.next([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete empty values and rebuild route. */
|
||||||
|
buildRoute(routeArr: string[]): string {
|
||||||
|
let route = '';
|
||||||
|
routeArr.forEach(item => {
|
||||||
|
if (item && item.trim()) {
|
||||||
|
route += '/' + item.replace(/^\/+|\/+$/g, '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the menu item name based on current route. */
|
||||||
|
getItemName(routeArr: string[]): string {
|
||||||
|
return this.getLevel(routeArr)[routeArr.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether is a leaf menu
|
||||||
|
private isLeafItem(item: MenuChildrenItem): boolean {
|
||||||
|
const cond0 = item.route === undefined;
|
||||||
|
const cond1 = item.children === undefined;
|
||||||
|
const cond2 = !cond1 && item.children?.length === 0;
|
||||||
|
return cond0 || cond1 || cond2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep clone object could be jsonized
|
||||||
|
private deepClone(obj: any): any {
|
||||||
|
return JSON.parse(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether two objects could be jsonized equal
|
||||||
|
private isJsonObjEqual(obj0: any, obj1: any): boolean {
|
||||||
|
return JSON.stringify(obj0) === JSON.stringify(obj1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether routeArr equals realRouteArr (after remove empty route element)
|
||||||
|
private isRouteEqual(routeArr: Array<string>, realRouteArr: Array<string>): boolean {
|
||||||
|
realRouteArr = this.deepClone(realRouteArr);
|
||||||
|
realRouteArr = realRouteArr.filter(r => r !== '');
|
||||||
|
return this.isJsonObjEqual(routeArr, realRouteArr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the menu level. */
|
||||||
|
getLevel(routeArr: string[]): string[] {
|
||||||
|
let tmpArr: any[] = [];
|
||||||
|
this.menu$.value.forEach(item => {
|
||||||
|
// Breadth-first traverse
|
||||||
|
let unhandledLayer = [{ item, parentNamePathList: [], realRouteArr: [] }];
|
||||||
|
while (unhandledLayer.length > 0) {
|
||||||
|
let nextUnhandledLayer: any[] = [];
|
||||||
|
for (const ele of unhandledLayer) {
|
||||||
|
const eachItem = ele.item;
|
||||||
|
const currentNamePathList = this.deepClone(ele.parentNamePathList).concat(eachItem.name);
|
||||||
|
const currentRealRouteArr = this.deepClone(ele.realRouteArr).concat(eachItem.route);
|
||||||
|
// Compare the full Array for expandable
|
||||||
|
if (this.isRouteEqual(routeArr, currentRealRouteArr)) {
|
||||||
|
tmpArr = currentNamePathList;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!this.isLeafItem(eachItem)) {
|
||||||
|
const wrappedChildren = eachItem.children?.map(child => ({
|
||||||
|
item: child,
|
||||||
|
parentNamePathList: currentNamePathList,
|
||||||
|
realRouteArr: currentRealRouteArr,
|
||||||
|
}));
|
||||||
|
nextUnhandledLayer = nextUnhandledLayer.concat(wrappedChildren);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unhandledLayer = nextUnhandledLayer;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return tmpArr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add namespace for translation. */
|
||||||
|
addNamespace(menu: Menu[] | MenuChildrenItem[], namespace: string) {
|
||||||
|
menu.forEach(menuItem => {
|
||||||
|
menuItem.name = `${namespace}.${menuItem.name}`;
|
||||||
|
if (menuItem.children && menuItem.children.length > 0) {
|
||||||
|
this.addNamespace(menuItem.children, menuItem.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
28
front/app/src/app/core/bootstrap/preloader.service.ts
Normal file
28
front/app/src/app/core/bootstrap/preloader.service.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Inject, Injectable } from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class PreloaderService {
|
||||||
|
private selector = 'globalLoader';
|
||||||
|
|
||||||
|
constructor(@Inject(DOCUMENT) private document: Document) {}
|
||||||
|
|
||||||
|
private getElement() {
|
||||||
|
return this.document.getElementById(this.selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
const el = this.getElement();
|
||||||
|
if (el) {
|
||||||
|
el.addEventListener('transitionend', () => {
|
||||||
|
el.className = 'global-loader-hidden';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!el.classList.contains('global-loader-hidden')) {
|
||||||
|
el.className += ' global-loader-fade-in';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
front/app/src/app/core/bootstrap/sanctum.service.spec.ts
Normal file
78
front/app/src/app/core/bootstrap/sanctum.service.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { BASE_URL } from '../interceptors/base-url-interceptor';
|
||||||
|
import { SANCTUM_PREFIX, SanctumService } from '@core';
|
||||||
|
|
||||||
|
describe('SanctumService', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let http: HttpClient;
|
||||||
|
let sanctumService: SanctumService;
|
||||||
|
|
||||||
|
const setBaseUrlAndSanctumPrefix = (baseUrl: string | null, sanctumPrefix: string | null) => {
|
||||||
|
TestBed.overrideProvider(BASE_URL, { useValue: baseUrl });
|
||||||
|
TestBed.overrideProvider(SANCTUM_PREFIX, { useValue: sanctumPrefix });
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
sanctumService = TestBed.inject(SanctumService);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [
|
||||||
|
{ provide: BASE_URL, useValue: null },
|
||||||
|
{ provide: SANCTUM_PREFIX, useValue: null },
|
||||||
|
SanctumService,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('should get csrf cookie once', done => {
|
||||||
|
setBaseUrlAndSanctumPrefix(null, null);
|
||||||
|
|
||||||
|
sanctumService.load().then(data => {
|
||||||
|
expect(data).toEqual({ cookie: true });
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock.expectOne('/sanctum/csrf-cookie').flush({ cookie: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get csrf cookie with base url', done => {
|
||||||
|
setBaseUrlAndSanctumPrefix('http://foo.bar/api', '');
|
||||||
|
|
||||||
|
sanctumService.load().then((data: any) => {
|
||||||
|
expect(data).toEqual({ cookie: true });
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock.expectOne('http://foo.bar/sanctum/csrf-cookie').flush({ cookie: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get csrf cookie with sanctum prefix', done => {
|
||||||
|
setBaseUrlAndSanctumPrefix(null, '/foobar/');
|
||||||
|
|
||||||
|
sanctumService.load().then((data: any) => {
|
||||||
|
expect(data).toEqual({ cookie: true });
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock.expectOne('/foobar/csrf-cookie').flush({ cookie: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get csrf cookie with base url and sanctum prefix', done => {
|
||||||
|
setBaseUrlAndSanctumPrefix('http://foo.bar/api/', '/foobar');
|
||||||
|
|
||||||
|
sanctumService.load().then((data: any) => {
|
||||||
|
expect(data).toEqual({ cookie: true });
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock.expectOne('http://foo.bar/foobar/csrf-cookie').flush({ cookie: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
38
front/app/src/app/core/bootstrap/sanctum.service.ts
Normal file
38
front/app/src/app/core/bootstrap/sanctum.service.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { BASE_URL } from '../interceptors/base-url-interceptor';
|
||||||
|
|
||||||
|
export const SANCTUM_PREFIX = new InjectionToken<string>('SANCTUM_PREFIX');
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class SanctumService {
|
||||||
|
constructor(
|
||||||
|
private http: HttpClient,
|
||||||
|
@Optional() @Inject(BASE_URL) private baseUrl?: string,
|
||||||
|
@Optional() @Inject(SANCTUM_PREFIX) private prefix?: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
load(): Promise<unknown> {
|
||||||
|
return new Promise(resolve => this.toObservable().subscribe(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
toObservable(): Observable<any> {
|
||||||
|
return this.http.get(this.getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUrl(): string {
|
||||||
|
const prefix = this.prefix || 'sanctum';
|
||||||
|
const path = `/${prefix.replace(/^\/|\/$/g, '')}/csrf-cookie`;
|
||||||
|
|
||||||
|
if (!this.baseUrl) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(this.baseUrl);
|
||||||
|
|
||||||
|
return url.origin + path;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
front/app/src/app/core/bootstrap/settings.service.ts
Normal file
34
front/app/src/app/core/bootstrap/settings.service.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
import { AppSettings, defaults } from '../settings';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class SettingsService {
|
||||||
|
get notify(): Observable<Record<string, any>> {
|
||||||
|
return this.notify$.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify$ = new BehaviorSubject<Record<string, any>>({});
|
||||||
|
|
||||||
|
getOptions() {
|
||||||
|
return this.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptions(options: AppSettings) {
|
||||||
|
this.options = Object.assign(defaults, options);
|
||||||
|
this.notify$.next(this.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private options = defaults;
|
||||||
|
|
||||||
|
getLanguage() {
|
||||||
|
return this.options.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLanguage(lang: string) {
|
||||||
|
this.options.language = lang;
|
||||||
|
this.notify$.next({ lang });
|
||||||
|
}
|
||||||
|
}
|
||||||
87
front/app/src/app/core/bootstrap/startup.service.spec.ts
Normal file
87
front/app/src/app/core/bootstrap/startup.service.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { NgxPermissionsModule, NgxPermissionsService, NgxRolesService } from 'ngx-permissions';
|
||||||
|
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||||
|
import { admin, TokenService } from '@core/authentication';
|
||||||
|
import { MenuService } from '@core/bootstrap/menu.service';
|
||||||
|
import { StartupService } from '@core/bootstrap/startup.service';
|
||||||
|
|
||||||
|
describe('StartupService', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let startup: StartupService;
|
||||||
|
let tokenService: TokenService;
|
||||||
|
let menuService: MenuService;
|
||||||
|
let mockPermissionsService: NgxPermissionsService;
|
||||||
|
let mockRolesService: NgxRolesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule, NgxPermissionsModule.forRoot()],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: LocalStorageService,
|
||||||
|
useClass: MemoryStorageService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: NgxPermissionsService,
|
||||||
|
useValue: {
|
||||||
|
loadPermissions: (permissions: string[]) => void 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: NgxRolesService,
|
||||||
|
useValue: {
|
||||||
|
flushRoles: () => void 0,
|
||||||
|
addRoles: (params: { ADMIN: string[] }) => void 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StartupService,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
startup = TestBed.inject(StartupService);
|
||||||
|
tokenService = TestBed.inject(TokenService);
|
||||||
|
menuService = TestBed.inject(MenuService);
|
||||||
|
mockPermissionsService = TestBed.inject(NgxPermissionsService);
|
||||||
|
mockRolesService = TestBed.inject(NgxRolesService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('should load menu when token changed and token valid', async () => {
|
||||||
|
const menuData = { menu: [] };
|
||||||
|
const permissions = ['canAdd', 'canDelete', 'canEdit', 'canRead'];
|
||||||
|
spyOn(menuService, 'addNamespace');
|
||||||
|
spyOn(menuService, 'set');
|
||||||
|
spyOn(mockPermissionsService, 'loadPermissions');
|
||||||
|
spyOn(mockRolesService, 'flushRoles');
|
||||||
|
spyOn(mockRolesService, 'addRoles');
|
||||||
|
|
||||||
|
await startup.load();
|
||||||
|
|
||||||
|
tokenService.set({ access_token: 'token', token_type: 'bearer' });
|
||||||
|
|
||||||
|
httpMock.expectOne('/me').flush(admin);
|
||||||
|
httpMock.expectOne('/me/menu').flush(menuData);
|
||||||
|
|
||||||
|
expect(menuService.addNamespace).toHaveBeenCalledWith(menuData.menu, 'menu');
|
||||||
|
expect(menuService.set).toHaveBeenCalledWith(menuData.menu);
|
||||||
|
expect(mockPermissionsService.loadPermissions).toHaveBeenCalledWith(permissions);
|
||||||
|
expect(mockRolesService.flushRoles).toHaveBeenCalledWith();
|
||||||
|
expect(mockRolesService.addRoles).toHaveBeenCalledWith({ ADMIN: permissions });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear menu when token changed and token invalid', async () => {
|
||||||
|
spyOn(menuService, 'addNamespace');
|
||||||
|
spyOn(menuService, 'set');
|
||||||
|
|
||||||
|
await startup.load();
|
||||||
|
|
||||||
|
tokenService.set({ access_token: '', token_type: 'bearer' });
|
||||||
|
|
||||||
|
httpMock.expectNone('/me/menu');
|
||||||
|
|
||||||
|
expect(menuService.addNamespace).toHaveBeenCalledWith([], 'menu');
|
||||||
|
expect(menuService.set).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
front/app/src/app/core/bootstrap/startup.service.ts
Normal file
53
front/app/src/app/core/bootstrap/startup.service.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { switchMap, tap } from 'rxjs/operators';
|
||||||
|
import { NgxPermissionsService, NgxRolesService } from 'ngx-permissions';
|
||||||
|
import { AuthService, User } from '@core/authentication';
|
||||||
|
import { Menu, MenuService } from './menu.service';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class StartupService {
|
||||||
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
|
private menuService: MenuService,
|
||||||
|
private permissonsService: NgxPermissionsService,
|
||||||
|
private rolesService: NgxRolesService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the application only after get the menu or other essential informations
|
||||||
|
* such as permissions and roles.
|
||||||
|
*/
|
||||||
|
load() {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
this.authService
|
||||||
|
.change()
|
||||||
|
.pipe(
|
||||||
|
tap(user => this.setPermissions(user)),
|
||||||
|
switchMap(() => this.authService.menu()),
|
||||||
|
tap(menu => this.setMenu(menu))
|
||||||
|
)
|
||||||
|
.subscribe(
|
||||||
|
() => resolve(),
|
||||||
|
() => resolve()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setMenu(menu: Menu[]) {
|
||||||
|
this.menuService.addNamespace(menu, 'menu');
|
||||||
|
this.menuService.set(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPermissions(user: User) {
|
||||||
|
// In a real app, you should get permissions and roles from the user information.
|
||||||
|
const permissions = ['canAdd', 'canDelete', 'canEdit', 'canRead'];
|
||||||
|
this.permissonsService.loadPermissions(permissions);
|
||||||
|
this.rolesService.flushRoles();
|
||||||
|
this.rolesService.addRoles({ ADMIN: permissions });
|
||||||
|
|
||||||
|
// Tips: Alternatively you can add permissions with role at the same time.
|
||||||
|
// this.rolesService.addRolesWithPermissions({ ADMIN: permissions });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
front/app/src/app/core/bootstrap/translate-lang.service.ts
Normal file
33
front/app/src/app/core/bootstrap/translate-lang.service.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Injectable, Injector } from '@angular/core';
|
||||||
|
import { LOCATION_INITIALIZED } from '@angular/common';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class TranslateLangService {
|
||||||
|
constructor(
|
||||||
|
private injector: Injector,
|
||||||
|
private translate: TranslateService,
|
||||||
|
private settings: SettingsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
const locationInitialized = this.injector.get(LOCATION_INITIALIZED, Promise.resolve());
|
||||||
|
locationInitialized.then(() => {
|
||||||
|
const browserLang = navigator.language;
|
||||||
|
const defaultLang = browserLang.match(/en-US|zh-CN|zh-TW/) ? browserLang : 'en-US';
|
||||||
|
|
||||||
|
this.settings.setLanguage(defaultLang);
|
||||||
|
this.translate.setDefaultLang(defaultLang);
|
||||||
|
this.translate.use(defaultLang).subscribe(
|
||||||
|
() => console.log(`Successfully initialized '${defaultLang}' language.'`),
|
||||||
|
() => console.error(`Problem with '${defaultLang}' language initialization.'`),
|
||||||
|
() => resolve()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
front/app/src/app/core/core.module.ts
Normal file
13
front/app/src/app/core/core.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NgModule, Optional, SkipSelf } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { throwIfAlreadyLoaded } from './module-import-guard';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [],
|
||||||
|
imports: [CommonModule],
|
||||||
|
})
|
||||||
|
export class CoreModule {
|
||||||
|
constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
|
||||||
|
throwIfAlreadyLoaded(parentModule, 'CoreModule');
|
||||||
|
}
|
||||||
|
}
|
||||||
16
front/app/src/app/core/index.ts
Normal file
16
front/app/src/app/core/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export * from './settings';
|
||||||
|
export * from './initializers';
|
||||||
|
|
||||||
|
// Bootstrap
|
||||||
|
export * from './bootstrap/menu.service';
|
||||||
|
export * from './bootstrap/settings.service';
|
||||||
|
export * from './bootstrap/startup.service';
|
||||||
|
export * from './bootstrap/preloader.service';
|
||||||
|
export * from './bootstrap/translate-lang.service';
|
||||||
|
export * from './bootstrap/sanctum.service';
|
||||||
|
|
||||||
|
// Interceptors
|
||||||
|
export * from './interceptors';
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
export * from './authentication';
|
||||||
37
front/app/src/app/core/initializers.ts
Normal file
37
front/app/src/app/core/initializers.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { APP_INITIALIZER } from '@angular/core';
|
||||||
|
|
||||||
|
// import { SanctumService } from './bootstrap/sanctum.service';
|
||||||
|
// export function SanctumServiceFactory(sanctumService: SanctumService) {
|
||||||
|
// return () => sanctumService.load();
|
||||||
|
// }
|
||||||
|
|
||||||
|
import { TranslateLangService } from './bootstrap/translate-lang.service';
|
||||||
|
export function TranslateLangServiceFactory(translateLangService: TranslateLangService) {
|
||||||
|
return () => translateLangService.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
import { StartupService } from './bootstrap/startup.service';
|
||||||
|
export function StartupServiceFactory(startupService: StartupService) {
|
||||||
|
return () => startupService.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appInitializerProviders = [
|
||||||
|
// {
|
||||||
|
// provide: APP_INITIALIZER,
|
||||||
|
// useFactory: SanctumServiceFactory,
|
||||||
|
// deps: [SanctumService],
|
||||||
|
// multi: true,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: TranslateLangServiceFactory,
|
||||||
|
deps: [TranslateLangService],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: StartupServiceFactory,
|
||||||
|
deps: [StartupService],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
3
front/app/src/app/core/interceptors/README.md
Normal file
3
front/app/src/app/core/interceptors/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Interceptors
|
||||||
|
|
||||||
|
https://angular.io/guide/http#intercepting-requests-and-responses
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { BASE_URL, BaseUrlInterceptor } from './base-url-interceptor';
|
||||||
|
|
||||||
|
describe('BaseUrlInterceptor', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let http: HttpClient;
|
||||||
|
const baseUrl = 'https://foo.bar';
|
||||||
|
|
||||||
|
const setBaseUrl = (url: string | null) => {
|
||||||
|
TestBed.overrideProvider(BASE_URL, { useValue: url });
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [
|
||||||
|
{ provide: BASE_URL, useValue: null },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: BaseUrlInterceptor, multi: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('should not prepend base url when base url is empty', () => {
|
||||||
|
setBaseUrl(null);
|
||||||
|
|
||||||
|
http.get('/me').subscribe(data => expect(data).toEqual({ success: true }));
|
||||||
|
|
||||||
|
httpMock.expectOne('/me').flush({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prepend base url when request url does not has http scheme', () => {
|
||||||
|
setBaseUrl(baseUrl);
|
||||||
|
|
||||||
|
http.get('./me').subscribe(data => expect(data).toEqual({ success: true }));
|
||||||
|
httpMock.expectOne(baseUrl + '/me').flush({ success: true });
|
||||||
|
|
||||||
|
http.get('').subscribe(data => expect(data).toEqual({ success: true }));
|
||||||
|
httpMock.expectOne(baseUrl).flush({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
24
front/app/src/app/core/interceptors/base-url-interceptor.ts
Normal file
24
front/app/src/app/core/interceptors/base-url-interceptor.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Inject, Injectable, InjectionToken, Optional } from '@angular/core';
|
||||||
|
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export const BASE_URL = new InjectionToken<string>('BASE_URL');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BaseUrlInterceptor implements HttpInterceptor {
|
||||||
|
private hasScheme = (url: string) => this.baseUrl && new RegExp('^http(s)?://', 'i').test(url);
|
||||||
|
|
||||||
|
constructor(@Optional() @Inject(BASE_URL) private baseUrl?: string) {}
|
||||||
|
|
||||||
|
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
return this.hasScheme(request.url) === false
|
||||||
|
? next.handle(request.clone({ url: this.prependBaseUrl(request.url) }))
|
||||||
|
: next.handle(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private prependBaseUrl(url: string) {
|
||||||
|
return [this.baseUrl?.replace(/\/$/g, ''), url.replace(/^\.?\//, '')]
|
||||||
|
.filter(val => val)
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
41
front/app/src/app/core/interceptors/default-interceptor.ts
Normal file
41
front/app/src/app/core/interceptors/default-interceptor.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
HttpEvent,
|
||||||
|
HttpHandler,
|
||||||
|
HttpInterceptor,
|
||||||
|
HttpRequest,
|
||||||
|
HttpResponse,
|
||||||
|
} from '@angular/common/http';
|
||||||
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
|
import { mergeMap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DefaultInterceptor implements HttpInterceptor {
|
||||||
|
constructor(private toast: ToastrService) {}
|
||||||
|
|
||||||
|
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
|
if (!req.url.includes('/api/')) {
|
||||||
|
return next.handle(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle(req).pipe(mergeMap((event: HttpEvent<any>) => this.handleOkReq(event)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleOkReq(event: HttpEvent<any>): Observable<any> {
|
||||||
|
if (event instanceof HttpResponse) {
|
||||||
|
const body: any = event.body;
|
||||||
|
// failure: { code: **, msg: 'failure' }
|
||||||
|
// success: { code: 0, msg: 'success', data: {} }
|
||||||
|
if (body && 'code' in body && body.code !== 0) {
|
||||||
|
if (body.msg) {
|
||||||
|
this.toast.error(body.msg);
|
||||||
|
}
|
||||||
|
return throwError([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pass down event if everything is OK
|
||||||
|
return of(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { ToastrModule, ToastrService } from 'ngx-toastr';
|
||||||
|
import { ErrorInterceptor } from './error-interceptor';
|
||||||
|
|
||||||
|
describe('ErrorInterceptor', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let http: HttpClient;
|
||||||
|
let router: Router;
|
||||||
|
let toast: ToastrService;
|
||||||
|
const emptyFn = () => {};
|
||||||
|
|
||||||
|
function assertStatus(status: number, statusText: string) {
|
||||||
|
spyOn(router, 'navigateByUrl');
|
||||||
|
|
||||||
|
http.get('/me').subscribe(emptyFn, emptyFn, emptyFn);
|
||||||
|
|
||||||
|
httpMock.expectOne('/me').flush({}, { status, statusText });
|
||||||
|
|
||||||
|
expect(router.navigateByUrl).toHaveBeenCalledWith(`/${status}`, {
|
||||||
|
skipLocationChange: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule, RouterTestingModule, ToastrModule.forRoot()],
|
||||||
|
providers: [{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
toast = TestBed.inject(ToastrService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('should handle status code 401', () => {
|
||||||
|
spyOn(router, 'navigateByUrl');
|
||||||
|
spyOn(toast, 'error');
|
||||||
|
|
||||||
|
http.get('/me').subscribe(emptyFn, emptyFn, emptyFn);
|
||||||
|
httpMock.expectOne('/me').flush({}, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('401 Unauthorized');
|
||||||
|
expect(router.navigateByUrl).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle status code 403', () => {
|
||||||
|
assertStatus(403, 'Forbidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle status code 404', () => {
|
||||||
|
assertStatus(404, 'Not Found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle status code 500', () => {
|
||||||
|
assertStatus(500, 'Internal Server Error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle others status code', () => {
|
||||||
|
spyOn(toast, 'error');
|
||||||
|
|
||||||
|
http.get('/me').subscribe(emptyFn, emptyFn, emptyFn);
|
||||||
|
|
||||||
|
httpMock.expectOne('/me').flush({}, { status: 504, statusText: 'Gateway Timeout' });
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('504 Gateway Timeout');
|
||||||
|
});
|
||||||
|
});
|
||||||
60
front/app/src/app/core/interceptors/error-interceptor.ts
Normal file
60
front/app/src/app/core/interceptors/error-interceptor.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
HttpErrorResponse,
|
||||||
|
HttpEvent,
|
||||||
|
HttpHandler,
|
||||||
|
HttpInterceptor,
|
||||||
|
HttpRequest,
|
||||||
|
} from '@angular/common/http';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Observable, throwError } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
import { ToastrService } from 'ngx-toastr';
|
||||||
|
|
||||||
|
export enum STATUS {
|
||||||
|
UNAUTHORIZED = 401,
|
||||||
|
FORBIDDEN = 403,
|
||||||
|
NOT_FOUND = 404,
|
||||||
|
INTERNAL_SERVER_ERROR = 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ErrorInterceptor implements HttpInterceptor {
|
||||||
|
private errorPages = [STATUS.FORBIDDEN, STATUS.NOT_FOUND, STATUS.INTERNAL_SERVER_ERROR];
|
||||||
|
|
||||||
|
private getMessage = (error: HttpErrorResponse) => {
|
||||||
|
if (error.error?.message) {
|
||||||
|
return error.error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.error?.msg) {
|
||||||
|
return error.error.msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${error.status} ${error.statusText}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private router: Router, private toast: ToastrService) {}
|
||||||
|
|
||||||
|
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
return next
|
||||||
|
.handle(request)
|
||||||
|
.pipe(catchError((error: HttpErrorResponse) => this.handleError(error)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(error: HttpErrorResponse) {
|
||||||
|
if (this.errorPages.includes(error.status)) {
|
||||||
|
this.router.navigateByUrl(`/${error.status}`, {
|
||||||
|
skipLocationChange: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('ERROR', error);
|
||||||
|
this.toast.error(this.getMessage(error));
|
||||||
|
if (error.status === STATUS.UNAUTHORIZED) {
|
||||||
|
this.router.navigateByUrl('/auth/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return throwError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
front/app/src/app/core/interceptors/index.ts
Normal file
31
front/app/src/app/core/interceptors/index.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { NoopInterceptor } from './noop-interceptor';
|
||||||
|
// import { SanctumInterceptor } from './sanctum-interceptor';
|
||||||
|
import { BaseUrlInterceptor } from './base-url-interceptor';
|
||||||
|
import { SettingsInterceptor } from './settings-interceptor';
|
||||||
|
import { TokenInterceptor } from './token-interceptor';
|
||||||
|
import { DefaultInterceptor } from './default-interceptor';
|
||||||
|
import { ErrorInterceptor } from './error-interceptor';
|
||||||
|
import { LoggingInterceptor } from './logging-interceptor';
|
||||||
|
|
||||||
|
export * from './noop-interceptor';
|
||||||
|
// export * from './sanctum-interceptor';
|
||||||
|
export * from './base-url-interceptor';
|
||||||
|
export * from './settings-interceptor';
|
||||||
|
export * from './token-interceptor';
|
||||||
|
export * from './default-interceptor';
|
||||||
|
export * from './error-interceptor';
|
||||||
|
export * from './logging-interceptor';
|
||||||
|
|
||||||
|
/** Http interceptor providers in outside-in order */
|
||||||
|
export const httpInterceptorProviders = [
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: NoopInterceptor, multi: true },
|
||||||
|
// { provide: HTTP_INTERCEPTORS, useClass: SanctumInterceptor, multi: true },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: BaseUrlInterceptor, multi: true },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: SettingsInterceptor, multi: true },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: DefaultInterceptor, multi: true },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
|
||||||
|
];
|
||||||
30
front/app/src/app/core/interceptors/logging-interceptor.ts
Normal file
30
front/app/src/app/core/interceptors/logging-interceptor.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpInterceptor, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http';
|
||||||
|
import { finalize, tap } from 'rxjs/operators';
|
||||||
|
import { MessageService } from '@shared';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LoggingInterceptor implements HttpInterceptor {
|
||||||
|
constructor(private messenger: MessageService) {}
|
||||||
|
|
||||||
|
intercept(req: HttpRequest<any>, next: HttpHandler) {
|
||||||
|
const started = Date.now();
|
||||||
|
let ok: string;
|
||||||
|
|
||||||
|
// extend server response observable with logging
|
||||||
|
return next.handle(req).pipe(
|
||||||
|
tap(
|
||||||
|
// Succeeds when there is a response; ignore other events
|
||||||
|
event => (ok = event instanceof HttpResponse ? 'succeeded' : ''),
|
||||||
|
// Operation failed; error is an HttpErrorResponse
|
||||||
|
error => (ok = 'failed')
|
||||||
|
),
|
||||||
|
// Log when response observable either completes or errors
|
||||||
|
finalize(() => {
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
const msg = `${req.method} "${req.urlWithParams}" ${ok} in ${elapsed} ms.`;
|
||||||
|
this.messenger.add(msg);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
front/app/src/app/core/interceptors/noop-interceptor.ts
Normal file
10
front/app/src/app/core/interceptors/noop-interceptor.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NoopInterceptor implements HttpInterceptor {
|
||||||
|
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
|
return next.handle(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||||
|
import { switchMap } from 'rxjs/operators';
|
||||||
|
import { SanctumInterceptor } from './sanctum-interceptor';
|
||||||
|
import { BASE_URL } from './base-url-interceptor';
|
||||||
|
import { SANCTUM_PREFIX } from '@core/bootstrap/sanctum.service';
|
||||||
|
|
||||||
|
describe('SanctumInterceptor', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let http: HttpClient;
|
||||||
|
|
||||||
|
const setBaseUrlAndSanctumPrefix = (baseUrl: string | null, sanctumPrefix: string | null) => {
|
||||||
|
TestBed.overrideProvider(BASE_URL, { useValue: baseUrl });
|
||||||
|
TestBed.overrideProvider(SANCTUM_PREFIX, { useValue: sanctumPrefix });
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [
|
||||||
|
{ provide: BASE_URL, useValue: null },
|
||||||
|
{ provide: SANCTUM_PREFIX, useValue: null },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: SanctumInterceptor, multi: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('should get csrf cookie once', () => {
|
||||||
|
setBaseUrlAndSanctumPrefix(null, null);
|
||||||
|
|
||||||
|
http
|
||||||
|
.post('/auth/login', {
|
||||||
|
username: 'foo',
|
||||||
|
password: 'bar',
|
||||||
|
})
|
||||||
|
.pipe(switchMap(() => http.get('/me')))
|
||||||
|
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||||
|
|
||||||
|
httpMock.expectOne('/sanctum/csrf-cookie').flush({ cookie: true });
|
||||||
|
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||||
|
httpMock.expectOne('/me').flush({ me: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get csrf cookie with base url', () => {
|
||||||
|
setBaseUrlAndSanctumPrefix('https://foo.bar/api', null);
|
||||||
|
|
||||||
|
http
|
||||||
|
.post('/auth/login', {
|
||||||
|
username: 'foo',
|
||||||
|
password: 'bar',
|
||||||
|
})
|
||||||
|
.pipe(switchMap(() => http.get('/me')))
|
||||||
|
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||||
|
|
||||||
|
httpMock.expectOne('https://foo.bar/sanctum/csrf-cookie').flush({ cookie: true });
|
||||||
|
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||||
|
httpMock.expectOne('/me').flush({ me: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get csrf cookie with sanctum prefix', () => {
|
||||||
|
setBaseUrlAndSanctumPrefix(null, 'foobar');
|
||||||
|
|
||||||
|
http
|
||||||
|
.post('/auth/login', {
|
||||||
|
username: 'foo',
|
||||||
|
password: 'bar',
|
||||||
|
})
|
||||||
|
.pipe(switchMap(() => http.get('/me')))
|
||||||
|
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||||
|
|
||||||
|
httpMock.expectOne('/foobar/csrf-cookie').flush({ cookie: true });
|
||||||
|
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||||
|
httpMock.expectOne('/me').flush({ me: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get csrf cookie with base url and sanctum prefix', () => {
|
||||||
|
setBaseUrlAndSanctumPrefix('https://foo.bar/api', 'foobar');
|
||||||
|
|
||||||
|
http
|
||||||
|
.post('/auth/login', {
|
||||||
|
username: 'foo',
|
||||||
|
password: 'bar',
|
||||||
|
})
|
||||||
|
.pipe(switchMap(() => http.get('/me')))
|
||||||
|
.subscribe(data => expect(data).toEqual({ me: true }));
|
||||||
|
|
||||||
|
httpMock.expectOne('https://foo.bar/foobar/csrf-cookie').flush({ cookie: true });
|
||||||
|
httpMock.expectOne('/auth/login').flush({ login: true });
|
||||||
|
httpMock.expectOne('/me').flush({ me: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
22
front/app/src/app/core/interceptors/sanctum-interceptor.ts
Normal file
22
front/app/src/app/core/interceptors/sanctum-interceptor.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { switchMap } from 'rxjs/operators';
|
||||||
|
import { SanctumService } from '@core';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SanctumInterceptor implements HttpInterceptor {
|
||||||
|
private ready = false;
|
||||||
|
|
||||||
|
constructor(private sanctum: SanctumService) {}
|
||||||
|
|
||||||
|
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
if (!this.ready) {
|
||||||
|
this.ready = true;
|
||||||
|
|
||||||
|
return this.sanctum.toObservable().pipe(switchMap(() => next.handle(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||||
|
import { SettingsService } from '@core/bootstrap/settings.service';
|
||||||
|
import { SettingsInterceptor } from './settings-interceptor';
|
||||||
|
|
||||||
|
describe('SettingsInterceptor', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let http: HttpClient;
|
||||||
|
let settings: SettingsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [{ provide: HTTP_INTERCEPTORS, useClass: SettingsInterceptor, multi: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
settings = TestBed.inject(SettingsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set accept language', () => {
|
||||||
|
settings.setLanguage('zh-TW');
|
||||||
|
|
||||||
|
http.get('/me').subscribe();
|
||||||
|
const testRequest = httpMock.expectOne('/me');
|
||||||
|
testRequest.flush({ me: true });
|
||||||
|
|
||||||
|
expect(testRequest.request.headers.get('Accept-Language')).toEqual('zh-TW');
|
||||||
|
});
|
||||||
|
});
|
||||||
17
front/app/src/app/core/interceptors/settings-interceptor.ts
Normal file
17
front/app/src/app/core/interceptors/settings-interceptor.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { SettingsService } from '@core';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SettingsInterceptor implements HttpInterceptor {
|
||||||
|
constructor(private settings: SettingsService) {}
|
||||||
|
|
||||||
|
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
return next.handle(
|
||||||
|
request.clone({
|
||||||
|
headers: request.headers.append('Accept-Language', this.settings.getLanguage()),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
front/app/src/app/core/interceptors/token-interceptor.spec.ts
Normal file
116
front/app/src/app/core/interceptors/token-interceptor.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { TokenInterceptor } from './token-interceptor';
|
||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||||
|
import { STATUS } from 'angular-in-memory-web-api';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { LocalStorageService, MemoryStorageService } from '@shared/services/storage.service';
|
||||||
|
import { TokenService, User } from '@core/authentication';
|
||||||
|
import { BASE_URL } from './base-url-interceptor';
|
||||||
|
|
||||||
|
describe('TokenInterceptor', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let http: HttpClient;
|
||||||
|
let router: Router;
|
||||||
|
let tokenService: TokenService;
|
||||||
|
const emptyFn = () => {};
|
||||||
|
const baseUrl = 'https://foo.bar';
|
||||||
|
const user: User = { id: 1, email: 'foo@bar.com' };
|
||||||
|
|
||||||
|
function init(url: string, access_token: string) {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule, RouterTestingModule],
|
||||||
|
providers: [
|
||||||
|
{ provide: LocalStorageService, useClass: MemoryStorageService },
|
||||||
|
{ provide: BASE_URL, useValue: url },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
tokenService = TestBed.inject(TokenService).set({ access_token, token_type: 'bearer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockRequest(url: string, body?: any, headers?: any) {
|
||||||
|
http.get(url).subscribe(emptyFn, emptyFn, emptyFn);
|
||||||
|
const testRequest = httpMock.expectOne(url);
|
||||||
|
testRequest.flush(body ?? {}, headers ?? {});
|
||||||
|
|
||||||
|
return testRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it('should append token when url does not has http scheme', () => {
|
||||||
|
init('', 'token');
|
||||||
|
|
||||||
|
const headers = mockRequest('/me', user).request.headers;
|
||||||
|
|
||||||
|
expect(headers.get('Authorization')).toEqual('Bearer token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append token when url does not has http and base url not empty', () => {
|
||||||
|
init(baseUrl, 'token');
|
||||||
|
|
||||||
|
const headers = mockRequest('/me', user).request.headers;
|
||||||
|
|
||||||
|
expect(headers.get('Authorization')).toEqual('Bearer token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append token when url include base url', () => {
|
||||||
|
init(baseUrl, 'token');
|
||||||
|
|
||||||
|
const headers = mockRequest(`${baseUrl}/me`, user).request.headers;
|
||||||
|
|
||||||
|
expect(headers.get('Authorization')).toEqual('Bearer token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not append token when url not include baseUrl', () => {
|
||||||
|
init(baseUrl, 'token');
|
||||||
|
|
||||||
|
const headers = mockRequest('https://api.github.com', { success: true }).request.headers;
|
||||||
|
|
||||||
|
expect(headers.has('Authorization')).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not append token when base url is empty and url is not same site', () => {
|
||||||
|
init('', 'token');
|
||||||
|
|
||||||
|
const headers = mockRequest('https://api.github.com', { success: true }).request.headers;
|
||||||
|
|
||||||
|
expect(headers.has('Authorization')).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear token when response status is unauthorized', () => {
|
||||||
|
init('', 'token');
|
||||||
|
spyOn(tokenService, 'clear');
|
||||||
|
|
||||||
|
mockRequest('/me', {}, { status: STATUS.UNAUTHORIZED, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
|
expect(tokenService.clear).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate /auth/login when api url is /auth/logout and token is valid', () => {
|
||||||
|
init('', 'token');
|
||||||
|
const navigateByUrl = spyOn(router, 'navigateByUrl');
|
||||||
|
navigateByUrl.and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
mockRequest('/auth/logout');
|
||||||
|
|
||||||
|
expect(navigateByUrl).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate /auth/login when api url is /auth/logout and token is invalid', () => {
|
||||||
|
init('', '');
|
||||||
|
const navigateByUrl = spyOn(router, 'navigateByUrl');
|
||||||
|
navigateByUrl.and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
mockRequest('/auth/logout');
|
||||||
|
|
||||||
|
expect(navigateByUrl).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
71
front/app/src/app/core/interceptors/token-interceptor.ts
Normal file
71
front/app/src/app/core/interceptors/token-interceptor.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Inject, Injectable, Optional } from '@angular/core';
|
||||||
|
import {
|
||||||
|
HttpErrorResponse,
|
||||||
|
HttpEvent,
|
||||||
|
HttpHandler,
|
||||||
|
HttpInterceptor,
|
||||||
|
HttpRequest,
|
||||||
|
} from '@angular/common/http';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { Observable, throwError } from 'rxjs';
|
||||||
|
import { catchError, tap } from 'rxjs/operators';
|
||||||
|
import { TokenService } from '@core/authentication';
|
||||||
|
import { BASE_URL } from './base-url-interceptor';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TokenInterceptor implements HttpInterceptor {
|
||||||
|
private hasHttpScheme = (url: string) => new RegExp('^http(s)?://', 'i').test(url);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private tokenService: TokenService,
|
||||||
|
private router: Router,
|
||||||
|
@Optional() @Inject(BASE_URL) private baseUrl?: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
const handler = () => {
|
||||||
|
if (request.url.includes('/auth/logout')) {
|
||||||
|
this.router.navigateByUrl('/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.router.url.includes('/auth/login')) {
|
||||||
|
this.router.navigateByUrl('/dashboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.tokenService.valid() && this.shouldAppendToken(request.url)) {
|
||||||
|
return next
|
||||||
|
.handle(
|
||||||
|
request.clone({
|
||||||
|
headers: request.headers.append('Authorization', this.tokenService.getBearerToken()),
|
||||||
|
withCredentials: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
catchError((error: HttpErrorResponse) => {
|
||||||
|
if (error.status === 401) {
|
||||||
|
this.tokenService.clear();
|
||||||
|
}
|
||||||
|
return throwError(error);
|
||||||
|
}),
|
||||||
|
tap(() => handler())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle(request).pipe(tap(() => handler()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldAppendToken(url: string) {
|
||||||
|
return !this.hasHttpScheme(url) || this.includeBaseUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private includeBaseUrl(url: string) {
|
||||||
|
if (!this.baseUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = this.baseUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
|
return new RegExp(`^${baseUrl}`, 'i').test(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
front/app/src/app/core/module-import-guard.ts
Normal file
7
front/app/src/app/core/module-import-guard.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function throwIfAlreadyLoaded(parentModule: any, moduleName: string) {
|
||||||
|
if (parentModule) {
|
||||||
|
throw new Error(
|
||||||
|
`${moduleName} has already been loaded. Import Core modules in the AppModule only.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
front/app/src/app/core/settings.ts
Normal file
23
front/app/src/app/core/settings.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface AppSettings {
|
||||||
|
navPos: 'side' | 'top';
|
||||||
|
theme: 'light' | 'dark' | 'auto';
|
||||||
|
dir: 'ltr' | 'rtl';
|
||||||
|
showHeader: boolean;
|
||||||
|
headerPos: 'fixed' | 'static' | 'above';
|
||||||
|
showUserPanel: boolean;
|
||||||
|
sidenavOpened: boolean;
|
||||||
|
sidenavCollapsed: boolean;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaults: AppSettings = {
|
||||||
|
navPos: 'side',
|
||||||
|
theme: 'auto',
|
||||||
|
dir: 'ltr',
|
||||||
|
showHeader: true,
|
||||||
|
headerPos: 'fixed',
|
||||||
|
showUserPanel: true,
|
||||||
|
sidenavOpened: true,
|
||||||
|
sidenavCollapsed: false,
|
||||||
|
language: 'en-US',
|
||||||
|
};
|
||||||
34
front/app/src/app/fake-login.service.ts
Normal file
34
front/app/src/app/fake-login.service.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { admin, LoginService, Menu } from '@core';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You should delete this file in the real APP.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FakeLoginService extends LoginService {
|
||||||
|
private token = { access_token: 'MW56YjMyOUAxNjMuY29tWm9uZ2Jpbg==', token_type: 'bearer' };
|
||||||
|
|
||||||
|
login() {
|
||||||
|
return of(this.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
return of(this.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
return of({});
|
||||||
|
}
|
||||||
|
|
||||||
|
me() {
|
||||||
|
return of(admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
menu() {
|
||||||
|
return this.http
|
||||||
|
.get<{ menu: Menu[] }>('assets/data/menu.json?_t=' + Date.now())
|
||||||
|
.pipe(map(res => res.menu));
|
||||||
|
}
|
||||||
|
}
|
||||||
53
front/app/src/app/formly-config.module.ts
Normal file
53
front/app/src/app/formly-config.module.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NgModule, ModuleWithProviders, Provider } from '@angular/core';
|
||||||
|
import { SharedModule } from './shared/shared.module';
|
||||||
|
|
||||||
|
import { FormlyModule } from '@ngx-formly/core';
|
||||||
|
import { FormlyFieldComboboxComponent } from './formly-templates';
|
||||||
|
import { FormlyWrapperCardComponent, FormlyWrapperDivComponent } from './formly-wrappers';
|
||||||
|
import { FormlyValidations } from './formly-validations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formly global configuration
|
||||||
|
*/
|
||||||
|
const formlyModuleProviders = FormlyModule.forRoot({
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
name: 'combobox',
|
||||||
|
component: FormlyFieldComboboxComponent,
|
||||||
|
wrappers: ['form-field'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
wrappers: [
|
||||||
|
{
|
||||||
|
name: 'card',
|
||||||
|
component: FormlyWrapperCardComponent,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'div',
|
||||||
|
component: FormlyWrapperDivComponent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
validationMessages: [],
|
||||||
|
}).providers as Provider[];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [SharedModule],
|
||||||
|
declarations: [
|
||||||
|
FormlyFieldComboboxComponent,
|
||||||
|
FormlyWrapperCardComponent,
|
||||||
|
FormlyWrapperDivComponent,
|
||||||
|
],
|
||||||
|
providers: [FormlyValidations],
|
||||||
|
})
|
||||||
|
export class FormlyConfigModule {
|
||||||
|
constructor(formlyValidations: FormlyValidations) {
|
||||||
|
formlyValidations.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
static forRoot(): ModuleWithProviders<FormlyConfigModule> {
|
||||||
|
return {
|
||||||
|
ngModule: FormlyConfigModule,
|
||||||
|
providers: [formlyModuleProviders],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
45
front/app/src/app/formly-templates.ts
Normal file
45
front/app/src/app/formly-templates.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ViewChild, ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
import { FieldType } from '@ngx-formly/material/form-field';
|
||||||
|
import { MtxSelect } from '@ng-matero/extensions/select';
|
||||||
|
import { FieldTypeConfig } from '@ngx-formly/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is just an example.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'formly-field-combobox',
|
||||||
|
template: `<mtx-select
|
||||||
|
#select
|
||||||
|
[formControl]="formControl"
|
||||||
|
[items]="props.options | toObservable | async"
|
||||||
|
[bindLabel]="bindLabel"
|
||||||
|
[bindValue]="bindValue!"
|
||||||
|
[multiple]="props.multiple"
|
||||||
|
[placeholder]="props.placeholder!"
|
||||||
|
[required]="props.required!"
|
||||||
|
[closeOnSelect]="!props.multiple"
|
||||||
|
[compareWith]="props.compareWith"
|
||||||
|
>
|
||||||
|
</mtx-select>`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class FormlyFieldComboboxComponent extends FieldType<FieldTypeConfig> {
|
||||||
|
@ViewChild('select', { static: true }) select!: MtxSelect;
|
||||||
|
|
||||||
|
get bindLabel() {
|
||||||
|
return typeof this.props.labelProp === 'string' ? this.props.labelProp : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get bindValue() {
|
||||||
|
return typeof this.props.valueProp === 'string' ? this.props.valueProp : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The original `onContainerClick` has been covered up in FieldType, so we should redefine it.
|
||||||
|
onContainerClick(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (/mat-form-field|mtx-select/g.test(target.parentElement?.classList[0] || '')) {
|
||||||
|
this.select.focus();
|
||||||
|
this.select.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
front/app/src/app/formly-validations.ts
Normal file
53
front/app/src/app/formly-validations.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { FormlyFieldConfig, FormlyConfig } from '@ngx-formly/core';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FormlyValidations {
|
||||||
|
constructor(private translate: TranslateService, private formlyConfig: FormlyConfig) {}
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
// message without params
|
||||||
|
this.formlyConfig.addValidatorMessage('required', (_err, _field) =>
|
||||||
|
this.translate.stream('validations.required')
|
||||||
|
);
|
||||||
|
|
||||||
|
// message with params
|
||||||
|
this.formlyConfig.addValidatorMessage('minLength', (err, field) =>
|
||||||
|
this.minLengthValidationMessage(err, field, this.translate)
|
||||||
|
);
|
||||||
|
this.formlyConfig.addValidatorMessage('maxLength', (err, field) =>
|
||||||
|
this.maxLengthValidationMessage(err, field, this.translate)
|
||||||
|
);
|
||||||
|
this.formlyConfig.addValidatorMessage('min', (err, field) =>
|
||||||
|
this.minValidationMessage(err, field, this.translate)
|
||||||
|
);
|
||||||
|
this.formlyConfig.addValidatorMessage('max', (err, field) =>
|
||||||
|
this.maxValidationMessage(err, field, this.translate)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private minLengthValidationMessage(
|
||||||
|
err: any,
|
||||||
|
field: FormlyFieldConfig,
|
||||||
|
translate: TranslateService
|
||||||
|
) {
|
||||||
|
return translate.stream('validations.minlength', { number: field.props?.minLength });
|
||||||
|
}
|
||||||
|
|
||||||
|
private maxLengthValidationMessage(
|
||||||
|
err: any,
|
||||||
|
field: FormlyFieldConfig,
|
||||||
|
translate: TranslateService
|
||||||
|
) {
|
||||||
|
return translate.stream('validations.maxlength', { number: field.props?.maxLength });
|
||||||
|
}
|
||||||
|
|
||||||
|
private minValidationMessage(err: any, field: FormlyFieldConfig, translate: TranslateService) {
|
||||||
|
return translate.stream('validations.min', { number: field.props?.min });
|
||||||
|
}
|
||||||
|
|
||||||
|
private maxValidationMessage(err: any, field: FormlyFieldConfig, translate: TranslateService) {
|
||||||
|
return translate.stream('validations.max', { number: field.props?.max });
|
||||||
|
}
|
||||||
|
}
|
||||||
29
front/app/src/app/formly-wrappers.ts
Normal file
29
front/app/src/app/formly-wrappers.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { FieldWrapper } from '@ngx-formly/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is just an example.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'formly-wrapper-card',
|
||||||
|
template: `
|
||||||
|
<div class="card">
|
||||||
|
<h3 class="card-header">Its time to party</h3>
|
||||||
|
<h3 class="card-header">{{ props.label }}</h3>
|
||||||
|
<div class="card-body">
|
||||||
|
<ng-container #fieldComponent></ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class FormlyWrapperCardComponent extends FieldWrapper {}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'formly-wrapper-div',
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<ng-container #fieldComponent></ng-container>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class FormlyWrapperDivComponent extends FieldWrapper {}
|
||||||
66
front/app/src/app/material-extensions.module.ts
Normal file
66
front/app/src/app/material-extensions.module.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { MtxAlertModule } from '@ng-matero/extensions/alert';
|
||||||
|
import { MtxButtonModule } from '@ng-matero/extensions/button';
|
||||||
|
import { MtxCheckboxGroupModule } from '@ng-matero/extensions/checkbox-group';
|
||||||
|
import { MtxColorpickerModule } from '@ng-matero/extensions/colorpicker';
|
||||||
|
import { MtxDatetimepickerModule } from '@ng-matero/extensions/datetimepicker';
|
||||||
|
import { MtxDialogModule } from '@ng-matero/extensions/dialog';
|
||||||
|
import { MtxDrawerModule } from '@ng-matero/extensions/drawer';
|
||||||
|
import { MtxGridModule } from '@ng-matero/extensions/grid';
|
||||||
|
import { MtxLoaderModule } from '@ng-matero/extensions/loader';
|
||||||
|
import { MtxPopoverModule } from '@ng-matero/extensions/popover';
|
||||||
|
import { MtxProgressModule } from '@ng-matero/extensions/progress';
|
||||||
|
import { MtxSelectModule } from '@ng-matero/extensions/select';
|
||||||
|
import { MtxSliderModule } from '@ng-matero/extensions/slider';
|
||||||
|
import { MtxSplitModule } from '@ng-matero/extensions/split';
|
||||||
|
import { MtxTooltipModule } from '@ng-matero/extensions/tooltip';
|
||||||
|
import { MTX_DATETIME_FORMATS } from '@ng-matero/extensions/core';
|
||||||
|
import { MtxMomentDatetimeModule } from '@ng-matero/extensions-moment-adapter';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
exports: [
|
||||||
|
MtxAlertModule,
|
||||||
|
MtxButtonModule,
|
||||||
|
MtxCheckboxGroupModule,
|
||||||
|
MtxColorpickerModule,
|
||||||
|
MtxDatetimepickerModule,
|
||||||
|
MtxDialogModule,
|
||||||
|
MtxDrawerModule,
|
||||||
|
MtxGridModule,
|
||||||
|
MtxLoaderModule,
|
||||||
|
MtxPopoverModule,
|
||||||
|
MtxProgressModule,
|
||||||
|
MtxSelectModule,
|
||||||
|
MtxSliderModule,
|
||||||
|
MtxSplitModule,
|
||||||
|
MtxTooltipModule,
|
||||||
|
MtxMomentDatetimeModule, // <= You can import the other adapter you need (luxon, date-fns)
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: MTX_DATETIME_FORMATS,
|
||||||
|
useValue: {
|
||||||
|
parse: {
|
||||||
|
dateInput: 'YYYY-MM-DD',
|
||||||
|
yearInput: 'YYYY',
|
||||||
|
monthInput: 'MMMM',
|
||||||
|
datetimeInput: 'YYYY-MM-DD HH:mm',
|
||||||
|
timeInput: 'HH:mm',
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
dateInput: 'YYYY-MM-DD',
|
||||||
|
yearInput: 'YYYY',
|
||||||
|
monthInput: 'MMMM',
|
||||||
|
datetimeInput: 'YYYY-MM-DD HH:mm',
|
||||||
|
timeInput: 'HH:mm',
|
||||||
|
monthYearLabel: 'YYYY MMMM',
|
||||||
|
dateA11yLabel: 'LL',
|
||||||
|
monthYearA11yLabel: 'MMMM YYYY',
|
||||||
|
popupHeaderDateLabel: 'MMM DD, ddd',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MaterialExtensionsModule {}
|
||||||
117
front/app/src/app/material.module.ts
Normal file
117
front/app/src/app/material.module.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
|
import { MatBadgeModule } from '@angular/material/badge';
|
||||||
|
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatRippleModule, MAT_DATE_FORMATS, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
|
import {
|
||||||
|
MatDialogConfig,
|
||||||
|
MatDialogModule,
|
||||||
|
MAT_DIALOG_DEFAULT_OPTIONS,
|
||||||
|
} from '@angular/material/dialog';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { MatExpansionModule } from '@angular/material/expansion';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatGridListModule } from '@angular/material/grid-list';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatListModule } from '@angular/material/list';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatPaginatorIntl, MatPaginatorModule } from '@angular/material/paginator';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatRadioModule } from '@angular/material/radio';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatSliderModule } from '@angular/material/slider';
|
||||||
|
import { MatSnackBarModule } from '@angular/material/snack-bar';
|
||||||
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
|
import { MatStepperModule } from '@angular/material/stepper';
|
||||||
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
import { MatTreeModule } from '@angular/material/tree';
|
||||||
|
import { MatMomentDateModule } from '@angular/material-moment-adapter';
|
||||||
|
|
||||||
|
import { PaginatorI18nService } from '@shared/services/paginator-i18n.service';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
exports: [
|
||||||
|
MatAutocompleteModule,
|
||||||
|
MatBadgeModule,
|
||||||
|
MatBottomSheetModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatButtonToggleModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatChipsModule,
|
||||||
|
MatStepperModule,
|
||||||
|
MatDatepickerModule,
|
||||||
|
MatMomentDateModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatDividerModule,
|
||||||
|
MatExpansionModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatGridListModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatListModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatRadioModule,
|
||||||
|
MatRippleModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatSidenavModule,
|
||||||
|
MatSliderModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatSnackBarModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatTableModule,
|
||||||
|
MatTabsModule,
|
||||||
|
MatToolbarModule,
|
||||||
|
MatTooltipModule,
|
||||||
|
MatTreeModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: MatPaginatorIntl,
|
||||||
|
deps: [PaginatorI18nService],
|
||||||
|
useFactory: (paginatorI18nSrv: PaginatorI18nService) => paginatorI18nSrv.getPaginatorIntl(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MAT_DIALOG_DEFAULT_OPTIONS,
|
||||||
|
useValue: {
|
||||||
|
...new MatDialogConfig(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MAT_DATE_LOCALE,
|
||||||
|
useFactory: () => navigator.language, // <= This will be overrided by runtime setting
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: MAT_DATE_FORMATS,
|
||||||
|
useValue: {
|
||||||
|
parse: {
|
||||||
|
dateInput: 'YYYY-MM-DD',
|
||||||
|
},
|
||||||
|
display: {
|
||||||
|
dateInput: 'YYYY-MM-DD',
|
||||||
|
monthYearLabel: 'YYYY MMM',
|
||||||
|
dateA11yLabel: 'LL',
|
||||||
|
monthYearA11yLabel: 'YYYY MMM',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MaterialModule {}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<page-header></page-header>
|
||||||
13
front/app/src/app/routes/dashboard/dashboard.component.ts
Normal file
13
front/app/src/app/routes/dashboard/dashboard.component.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard',
|
||||||
|
templateUrl: './dashboard.component.html',
|
||||||
|
styleUrls: ['./dashboard.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class DashboardComponent implements OnInit {
|
||||||
|
constructor(private cdr: ChangeDetectorRef) {}
|
||||||
|
|
||||||
|
ngOnInit() {}
|
||||||
|
}
|
||||||
48
front/app/src/app/routes/routes-routing.module.ts
Normal file
48
front/app/src/app/routes/routes-routing.module.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
import { environment } from '@env/environment';
|
||||||
|
|
||||||
|
import { AdminLayoutComponent } from '../theme/admin-layout/admin-layout.component';
|
||||||
|
import { AuthLayoutComponent } from '../theme/auth-layout/auth-layout.component';
|
||||||
|
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||||
|
import { LoginComponent } from './sessions/login/login.component';
|
||||||
|
import { RegisterComponent } from './sessions/register/register.component';
|
||||||
|
import { Error403Component } from './sessions/403.component';
|
||||||
|
import { Error404Component } from './sessions/404.component';
|
||||||
|
import { Error500Component } from './sessions/500.component';
|
||||||
|
import { AuthGuard } from '@core';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: AdminLayoutComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
canActivateChild: [AuthGuard],
|
||||||
|
children: [
|
||||||
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
|
{ path: 'dashboard', component: DashboardComponent },
|
||||||
|
{ path: '403', component: Error403Component },
|
||||||
|
{ path: '404', component: Error404Component },
|
||||||
|
{ path: '500', component: Error500Component },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'auth',
|
||||||
|
component: AuthLayoutComponent,
|
||||||
|
children: [
|
||||||
|
{ path: 'login', component: LoginComponent },
|
||||||
|
{ path: 'register', component: RegisterComponent },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '**', redirectTo: 'dashboard' },
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forRoot(routes, {
|
||||||
|
useHash: environment.useHash,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
exports: [RouterModule],
|
||||||
|
})
|
||||||
|
export class RoutesRoutingModule {}
|
||||||
26
front/app/src/app/routes/routes.module.ts
Normal file
26
front/app/src/app/routes/routes.module.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { SharedModule } from '@shared/shared.module';
|
||||||
|
import { RoutesRoutingModule } from './routes-routing.module';
|
||||||
|
|
||||||
|
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||||
|
import { LoginComponent } from './sessions/login/login.component';
|
||||||
|
import { RegisterComponent } from './sessions/register/register.component';
|
||||||
|
import { Error403Component } from './sessions/403.component';
|
||||||
|
import { Error404Component } from './sessions/404.component';
|
||||||
|
import { Error500Component } from './sessions/500.component';
|
||||||
|
|
||||||
|
const COMPONENTS: any[] = [
|
||||||
|
DashboardComponent,
|
||||||
|
LoginComponent,
|
||||||
|
RegisterComponent,
|
||||||
|
Error403Component,
|
||||||
|
Error404Component,
|
||||||
|
Error500Component,
|
||||||
|
];
|
||||||
|
const COMPONENTS_DYNAMIC: any[] = [];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [SharedModule, RoutesRoutingModule],
|
||||||
|
declarations: [...COMPONENTS, ...COMPONENTS_DYNAMIC],
|
||||||
|
})
|
||||||
|
export class RoutesModule {}
|
||||||
13
front/app/src/app/routes/sessions/403.component.ts
Normal file
13
front/app/src/app/routes/sessions/403.component.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-error-403',
|
||||||
|
template: `
|
||||||
|
<error-code
|
||||||
|
code="403"
|
||||||
|
[title]="'Permission denied!'"
|
||||||
|
[message]="'You do not have permission to access the requested data.'"
|
||||||
|
></error-code>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class Error403Component {}
|
||||||
13
front/app/src/app/routes/sessions/404.component.ts
Normal file
13
front/app/src/app/routes/sessions/404.component.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-error-404',
|
||||||
|
template: `
|
||||||
|
<error-code
|
||||||
|
code="404"
|
||||||
|
[title]="'Page not found!'"
|
||||||
|
[message]="'This is not the web page you are looking for.'"
|
||||||
|
></error-code>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class Error404Component {}
|
||||||
14
front/app/src/app/routes/sessions/500.component.ts
Normal file
14
front/app/src/app/routes/sessions/500.component.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-error-500',
|
||||||
|
template: `
|
||||||
|
<error-code
|
||||||
|
code="500"
|
||||||
|
[title]="'Server went wrong!'"
|
||||||
|
[message]="'Just kidding, looks like we have an internal issue, please try refreshing.'"
|
||||||
|
>
|
||||||
|
</error-code>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class Error500Component {}
|
||||||
45
front/app/src/app/routes/sessions/login/login.component.html
Normal file
45
front/app/src/app/routes/sessions/login/login.component.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<div class="d-flex w-full h-full">
|
||||||
|
<mat-card class="m-auto" style="max-width: 380px;">
|
||||||
|
<mat-card-header class="m-b-24">
|
||||||
|
<mat-card-title>{{'login.title' | translate}}!</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<form class="form-field-full" [formGroup]="loginForm">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>{{'login.username' | translate}}: ng-matero</mat-label>
|
||||||
|
<input matInput placeholder="ng-matero" formControlName="username" required>
|
||||||
|
<mat-error *ngIf="username.invalid">
|
||||||
|
<span *ngIf="username.errors?.required">{{'login.please_enter' | translate}}
|
||||||
|
<strong>ng-matero</strong>
|
||||||
|
</span>
|
||||||
|
<span *ngIf="username.errors?.remote">{{ username.errors?.remote }}</span>
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>{{'login.password' | translate}}: ng-matero</mat-label>
|
||||||
|
<input matInput placeholder="ng-matero" type="password"
|
||||||
|
formControlName="password" required>
|
||||||
|
<mat-error *ngIf="password.invalid">
|
||||||
|
<span *ngIf="password.errors?.required">{{'login.please_enter' | translate}}
|
||||||
|
<strong>ng-matero</strong>
|
||||||
|
</span>
|
||||||
|
<span *ngIf="password.errors?.remote">{{ password.errors?.remote }}</span>
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-checkbox formControlName="rememberMe">{{'login.remember_me' | translate}}
|
||||||
|
</mat-checkbox>
|
||||||
|
|
||||||
|
<button class="w-full m-y-16" mat-raised-button color="primary"
|
||||||
|
[disabled]="!!loginForm.invalid" [loading]="isSubmitting"
|
||||||
|
(click)="login()">{{'login.login' | translate}}</button>
|
||||||
|
|
||||||
|
<div>{{'login.have_no_account' | translate}}?
|
||||||
|
<a routerLink="/auth/register">{{'login.create_one' | translate}}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
58
front/app/src/app/routes/sessions/login/login.component.ts
Normal file
58
front/app/src/app/routes/sessions/login/login.component.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { FormBuilder, Validators } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { filter } from 'rxjs/operators';
|
||||||
|
import { AuthService } from '@core/authentication';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
templateUrl: './login.component.html',
|
||||||
|
styleUrls: ['./login.component.scss'],
|
||||||
|
})
|
||||||
|
export class LoginComponent {
|
||||||
|
isSubmitting = false;
|
||||||
|
|
||||||
|
loginForm = this.fb.nonNullable.group({
|
||||||
|
username: ['ng-matero', [Validators.required]],
|
||||||
|
password: ['ng-matero', [Validators.required]],
|
||||||
|
rememberMe: [false],
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder, private router: Router, private auth: AuthService) {}
|
||||||
|
|
||||||
|
get username() {
|
||||||
|
return this.loginForm.get('username')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
get password() {
|
||||||
|
return this.loginForm.get('password')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rememberMe() {
|
||||||
|
return this.loginForm.get('rememberMe')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
login() {
|
||||||
|
this.isSubmitting = true;
|
||||||
|
|
||||||
|
this.auth
|
||||||
|
.login(this.username.value, this.password.value, this.rememberMe.value)
|
||||||
|
.pipe(filter(authenticated => authenticated))
|
||||||
|
.subscribe(
|
||||||
|
() => this.router.navigateByUrl('/'),
|
||||||
|
(errorRes: HttpErrorResponse) => {
|
||||||
|
if (errorRes.status === 422) {
|
||||||
|
const form = this.loginForm;
|
||||||
|
const errors = errorRes.error.errors;
|
||||||
|
Object.keys(errors).forEach(key => {
|
||||||
|
form.get(key === 'email' ? 'username' : key)?.setErrors({
|
||||||
|
remote: errors[key][0],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.isSubmitting = false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<div class="d-flex w-full h-full">
|
||||||
|
<mat-card class="m-auto" style="max-width: 380px;">
|
||||||
|
<mat-card-header class="m-b-24">
|
||||||
|
<mat-card-title>
|
||||||
|
{{'register.welcome' | translate}}, <br />
|
||||||
|
{{'register.title' | translate}}
|
||||||
|
</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content>
|
||||||
|
<form class="form-field-full" [formGroup]="registerForm">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>{{'login.username' | translate}}</mat-label>
|
||||||
|
<input matInput formControlName="username" required>
|
||||||
|
<mat-error *ngIf="registerForm.get('username')?.invalid">
|
||||||
|
<span>{{'validations.required' | translate}}</span>
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>{{'login.password' | translate}}</mat-label>
|
||||||
|
<input matInput type="password" formControlName="password" required>
|
||||||
|
<mat-error *ngIf="registerForm.get('password')?.invalid">
|
||||||
|
<span>{{'validations.required' | translate}}</span>
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>{{'register.confirm_password' | translate}}</mat-label>
|
||||||
|
<input matInput type="password" formControlName="confirmPassword" required>
|
||||||
|
<mat-error *ngIf="registerForm.get('confirmPassword')?.hasError('required')">
|
||||||
|
<span>{{'validations.required' | translate}}</span>
|
||||||
|
</mat-error>
|
||||||
|
<mat-error *ngIf="registerForm.get('confirmPassword')?.hasError('mismatch')"
|
||||||
|
translate [translateParams]="{value: 'login.password' | translate}">
|
||||||
|
<span>{{'validations.inconsistent'}}</span>
|
||||||
|
</mat-error>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-checkbox>{{'register.agree' | translate}}</mat-checkbox>
|
||||||
|
|
||||||
|
<button class="w-full m-y-16" mat-raised-button color="primary">
|
||||||
|
{{'register.register' | translate}}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div>{{'register.have_an_account' | translate}}?
|
||||||
|
<a routerLink="/auth/login">{{'login.login' | translate}}</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { FormBuilder, Validators, AbstractControl } from '@angular/forms';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-register',
|
||||||
|
templateUrl: './register.component.html',
|
||||||
|
styleUrls: ['./register.component.scss'],
|
||||||
|
})
|
||||||
|
export class RegisterComponent {
|
||||||
|
registerForm = this.fb.nonNullable.group(
|
||||||
|
{
|
||||||
|
username: ['', [Validators.required]],
|
||||||
|
password: ['', [Validators.required]],
|
||||||
|
confirmPassword: ['', [Validators.required]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validators: [this.matchValidator('password', 'confirmPassword')],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder) {}
|
||||||
|
|
||||||
|
matchValidator(source: string, target: string) {
|
||||||
|
return (control: AbstractControl) => {
|
||||||
|
const sourceControl = control.get(source)!;
|
||||||
|
const targetControl = control.get(target)!;
|
||||||
|
if (targetControl.errors && !targetControl.errors.mismatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (sourceControl.value !== targetControl.value) {
|
||||||
|
targetControl.setErrors({ mismatch: true });
|
||||||
|
return { mismatch: true };
|
||||||
|
} else {
|
||||||
|
targetControl.setErrors(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="matero-breadcrumb">
|
||||||
|
<li class="matero-breadcrumb-item"
|
||||||
|
*ngFor="let navLink of nav; trackBy: trackByNavlink; first as isFirst;">
|
||||||
|
<a href="#" class="link" *ngIf="isFirst">{{navLink}}</a>
|
||||||
|
<ng-container *ngIf="!isFirst">
|
||||||
|
<mat-icon class="chevron">chevron_right</mat-icon>
|
||||||
|
<span>{{navLink | translate}}</span>
|
||||||
|
</ng-container>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.matero-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
font-size: .875rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matero-breadcrumb-item {
|
||||||
|
line-height: 18px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
> a.link {
|
||||||
|
color: currentColor;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: currentColor;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .chevron {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Component, OnInit, ViewEncapsulation, Input } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { MenuService } from '@core/bootstrap/menu.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'breadcrumb',
|
||||||
|
templateUrl: './breadcrumb.component.html',
|
||||||
|
styleUrls: ['./breadcrumb.component.scss'],
|
||||||
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
})
|
||||||
|
export class BreadcrumbComponent implements OnInit {
|
||||||
|
@Input() nav: string[] = [];
|
||||||
|
|
||||||
|
constructor(private router: Router, private menu: MenuService) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.nav = Array.isArray(this.nav) ? this.nav : [];
|
||||||
|
|
||||||
|
if (this.nav.length === 0) {
|
||||||
|
this.genBreadcrumb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByNavlink(index: number, navLink: string): string {
|
||||||
|
return navLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
genBreadcrumb() {
|
||||||
|
const routes = this.router.url.slice(1).split('/');
|
||||||
|
this.nav = this.menu.getLevel(routes);
|
||||||
|
this.nav.unshift('home');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@use 'sass:map';
|
||||||
|
@use '@angular/material' as mat;
|
||||||
|
|
||||||
|
@mixin theme($theme) {
|
||||||
|
$background: map.get($theme, background);
|
||||||
|
$foreground: map.get($theme, foreground);
|
||||||
|
|
||||||
|
.matero-error-code {
|
||||||
|
color: mat.get-color-from-palette($foreground, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Long Shadow
|
||||||
|
//
|
||||||
|
// https://codepen.io/c_fitzmaurice/pen/ZYJeRY
|
||||||
|
|
||||||
|
@use 'sass:color';
|
||||||
|
@use 'sass:list';
|
||||||
|
@use 'sass:meta';
|
||||||
|
@use 'sass:map';
|
||||||
|
@use 'sass:math';
|
||||||
|
|
||||||
|
@function long-shadow($direction, $length, $color, $fade: false, $shadow-count: 100) {
|
||||||
|
$shadows: ();
|
||||||
|
$conversion-map: (
|
||||||
|
to top: 180deg,
|
||||||
|
to top right: 135deg,
|
||||||
|
to right top: 135deg,
|
||||||
|
to right: 90deg,
|
||||||
|
to bottom right: 45deg,
|
||||||
|
to right bottom: 45deg,
|
||||||
|
to bottom: 0deg,
|
||||||
|
to bottom left: 315deg,
|
||||||
|
to left bottom: 315deg,
|
||||||
|
to left: 270deg,
|
||||||
|
to left top: 225deg,
|
||||||
|
to top left: 225deg
|
||||||
|
);
|
||||||
|
|
||||||
|
@if map-has-key($conversion-map, $direction) {
|
||||||
|
$direction: map.get($conversion-map, $direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 1 through $shadow-count {
|
||||||
|
$current-step: math.div($i * $length, $shadow-count);
|
||||||
|
$current-color: if(
|
||||||
|
not $fade,
|
||||||
|
$color,
|
||||||
|
if(
|
||||||
|
meta.type-of($fade) == 'color',
|
||||||
|
color.mix($fade, $color, math.div($i, $shadow-count) * 100%),
|
||||||
|
color.rgba($color, 1 - math.div($i, $shadow-count))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$shadows: list.append(
|
||||||
|
$shadows,
|
||||||
|
(math.sin(0deg + $direction) * $current-step)
|
||||||
|
(math.cos(0deg + $direction) * $current-step)
|
||||||
|
0
|
||||||
|
$current-color,
|
||||||
|
'comma'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@return $shadows;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<div class="matero-error-wrap">
|
||||||
|
<div class="matero-error-code">{{code}}</div>
|
||||||
|
<div class="matero-error-title" *ngIf="title">{{title}}</div>
|
||||||
|
<div class="matero-error-message" *ngIf="message">{{message}}</div>
|
||||||
|
<div><a mat-raised-button color="primary" routerLink="/">Back to Home</a></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@use 'long-shadow';
|
||||||
|
|
||||||
|
.matero-error-wrap {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matero-error-code {
|
||||||
|
padding: 20px 0;
|
||||||
|
font-size: 150px;
|
||||||
|
text-shadow:
|
||||||
|
long-shadow.long-shadow(
|
||||||
|
$direction: 45deg,
|
||||||
|
$length: 60px,
|
||||||
|
$color: rgba(0, 0, 0, .03),
|
||||||
|
$fade: rgba(0, 0, 0, .0015),
|
||||||
|
$shadow-count: 20
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matero-error-title {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matero-error-message {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Component, ViewEncapsulation, Input } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'error-code',
|
||||||
|
templateUrl: './error-code.component.html',
|
||||||
|
styleUrls: ['./error-code.component.scss'],
|
||||||
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
})
|
||||||
|
export class ErrorCodeComponent {
|
||||||
|
@Input() code = '';
|
||||||
|
@Input() title = '';
|
||||||
|
@Input() message = '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="matero-page-header-inner">
|
||||||
|
<h1 class="matero-page-title">{{title | translate}} <small>{{subtitle}}</small></h1>
|
||||||
|
<breadcrumb *ngIf="!hideBreadcrumb" [nav]="nav"></breadcrumb>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.matero-page-header {
|
||||||
|
display: block;
|
||||||
|
margin: -16px -16px 16px;
|
||||||
|
padding: 16px;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #3f51b5;
|
||||||
|
|
||||||
|
.matero-breadcrumb {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.matero-page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Component, OnInit, ViewEncapsulation, Input, HostBinding } from '@angular/core';
|
||||||
|
import { MenuService } from '@core/bootstrap/menu.service';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'page-header',
|
||||||
|
templateUrl: './page-header.component.html',
|
||||||
|
styleUrls: ['./page-header.component.scss'],
|
||||||
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
})
|
||||||
|
export class PageHeaderComponent implements OnInit {
|
||||||
|
@HostBinding('class') class = 'matero-page-header';
|
||||||
|
|
||||||
|
@Input() title = '';
|
||||||
|
@Input() subtitle = '';
|
||||||
|
@Input() nav: string[] = [];
|
||||||
|
@Input()
|
||||||
|
get hideBreadcrumb() {
|
||||||
|
return this._hideBreadCrumb;
|
||||||
|
}
|
||||||
|
set hideBreadcrumb(value: boolean) {
|
||||||
|
this._hideBreadCrumb = coerceBooleanProperty(value);
|
||||||
|
}
|
||||||
|
private _hideBreadCrumb = false;
|
||||||
|
|
||||||
|
constructor(private router: Router, private menu: MenuService) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.nav = Array.isArray(this.nav) ? this.nav : [];
|
||||||
|
|
||||||
|
if (this.nav.length === 0) {
|
||||||
|
this.genBreadcrumb();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.title = this.title || this.nav[this.nav.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
genBreadcrumb() {
|
||||||
|
const routes = this.router.url.slice(1).split('/');
|
||||||
|
this.nav = this.menu.getLevel(routes);
|
||||||
|
this.nav.unshift('home');
|
||||||
|
}
|
||||||
|
|
||||||
|
static ngAcceptInputType_hideBreadcrumb: BooleanInput;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NgControl } from '@angular/forms';
|
||||||
|
import { Directive, Input, SkipSelf, Optional } from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[disableControl]',
|
||||||
|
})
|
||||||
|
export class DisableControlDirective {
|
||||||
|
@Input() set disableControl(condition: boolean) {
|
||||||
|
const action = condition ? 'disable' : 'enable';
|
||||||
|
this.ngControl.control?.[action]();
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(@Optional() @SkipSelf() private ngControl: NgControl) {}
|
||||||
|
}
|
||||||
16
front/app/src/app/shared/in-mem/in-mem-data.service.spec.ts
Normal file
16
front/app/src/app/shared/in-mem/in-mem-data.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { InMemDataService } from './in-mem-data.service';
|
||||||
|
|
||||||
|
describe('InMemDataService', () => {
|
||||||
|
let service: InMemDataService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(InMemDataService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
209
front/app/src/app/shared/in-mem/in-mem-data.service.ts
Normal file
209
front/app/src/app/shared/in-mem/in-mem-data.service.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpRequest } from '@angular/common/http';
|
||||||
|
import { InMemoryDbService, RequestInfo, STATUS } from 'angular-in-memory-web-api';
|
||||||
|
import { from, Observable } from 'rxjs';
|
||||||
|
import { ajax } from 'rxjs/ajax';
|
||||||
|
import { find, map, switchMap } from 'rxjs/operators';
|
||||||
|
import { environment } from '@env/environment';
|
||||||
|
import { base64, currentTimestamp, filterObject, User } from '@core/authentication';
|
||||||
|
|
||||||
|
class JWT {
|
||||||
|
generate(user: User) {
|
||||||
|
const expiresIn = 3600;
|
||||||
|
const refreshTokenExpiresIn = 86400;
|
||||||
|
|
||||||
|
return filterObject({
|
||||||
|
access_token: this.createToken(user, expiresIn),
|
||||||
|
token_type: 'bearer',
|
||||||
|
expires_in: user.refresh_token ? expiresIn : undefined,
|
||||||
|
refresh_token: user.refresh_token ? this.createToken(user, refreshTokenExpiresIn) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getUser(req: HttpRequest<any>) {
|
||||||
|
let token = '';
|
||||||
|
|
||||||
|
if (req.body?.refresh_token) {
|
||||||
|
token = req.body.refresh_token;
|
||||||
|
} else if (req.headers.has('Authorization')) {
|
||||||
|
const authorization = req.headers.get('Authorization');
|
||||||
|
const result = (authorization as string).split(' ');
|
||||||
|
token = result[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const data = JWT.parseToken(token);
|
||||||
|
|
||||||
|
return JWT.isExpired(data, now) ? null : data.user;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createToken(user: User, expiresIn = 0) {
|
||||||
|
const exp = user.refresh_token ? currentTimestamp() + expiresIn : undefined;
|
||||||
|
|
||||||
|
return [
|
||||||
|
base64.encode(JSON.stringify({ typ: 'JWT', alg: 'HS256' })),
|
||||||
|
base64.encode(JSON.stringify(filterObject(Object.assign({ exp, user })))),
|
||||||
|
base64.encode('ng-matero'),
|
||||||
|
].join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseToken(accessToken: string) {
|
||||||
|
const [, payload] = accessToken.split('.');
|
||||||
|
|
||||||
|
return JSON.parse(base64.decode(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isExpired(data: any, now: Date) {
|
||||||
|
const expiresIn = new Date();
|
||||||
|
expiresIn.setTime(data.exp * 1000);
|
||||||
|
const diff = this.dateToSeconds(expiresIn) - this.dateToSeconds(now);
|
||||||
|
|
||||||
|
return diff <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static dateToSeconds(date: Date) {
|
||||||
|
return Math.ceil(date.getTime() / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwt = new JWT();
|
||||||
|
|
||||||
|
function is(reqInfo: RequestInfo, path: string) {
|
||||||
|
if (environment.baseUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RegExp(`${path}(/)?$`, 'i').test(reqInfo.req.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class InMemDataService implements InMemoryDbService {
|
||||||
|
private users: User[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'ng-matero',
|
||||||
|
password: 'ng-matero',
|
||||||
|
name: 'Zongbin',
|
||||||
|
email: 'nzb329@163.com',
|
||||||
|
avatar: './assets/images/avatar.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
username: 'recca0120',
|
||||||
|
password: 'password',
|
||||||
|
name: 'recca0120',
|
||||||
|
email: 'recca0120@gmail.com',
|
||||||
|
avatar: './assets/images/avatars/avatar-10.jpg',
|
||||||
|
refresh_token: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
createDb(
|
||||||
|
reqInfo?: RequestInfo
|
||||||
|
):
|
||||||
|
| Record<string, unknown>
|
||||||
|
| Observable<Record<string, unknown>>
|
||||||
|
| Promise<Record<string, unknown>> {
|
||||||
|
return { users: this.users };
|
||||||
|
}
|
||||||
|
|
||||||
|
get(reqInfo: RequestInfo) {
|
||||||
|
const { headers, url } = reqInfo;
|
||||||
|
|
||||||
|
if (is(reqInfo, 'sanctum/csrf-cookie')) {
|
||||||
|
const response = { headers, url, status: STATUS.NO_CONTENT, body: {} };
|
||||||
|
|
||||||
|
return reqInfo.utils.createResponse$(() => response);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is(reqInfo, 'me/menu')) {
|
||||||
|
return ajax('assets/data/menu.json?_t=' + Date.now()).pipe(
|
||||||
|
map((response: any) => {
|
||||||
|
return { headers, url, status: STATUS.OK, body: { menu: response.response.menu } };
|
||||||
|
}),
|
||||||
|
switchMap(response => reqInfo.utils.createResponse$(() => response))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is(reqInfo, 'me')) {
|
||||||
|
const user = jwt.getUser(reqInfo.req as HttpRequest<any>);
|
||||||
|
const result = user
|
||||||
|
? { status: STATUS.OK, body: user }
|
||||||
|
: { status: STATUS.UNAUTHORIZED, body: {} };
|
||||||
|
const response = Object.assign({ headers, url }, result);
|
||||||
|
|
||||||
|
return reqInfo.utils.createResponse$(() => response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
post(reqInfo: RequestInfo) {
|
||||||
|
if (is(reqInfo, 'auth/login')) {
|
||||||
|
return this.login(reqInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is(reqInfo, 'auth/refresh')) {
|
||||||
|
return this.refresh(reqInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is(reqInfo, 'auth/logout')) {
|
||||||
|
return this.logout(reqInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
private login(reqInfo: RequestInfo) {
|
||||||
|
const { headers, url } = reqInfo;
|
||||||
|
const req = reqInfo.req as HttpRequest<any>;
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
return from(this.users).pipe(
|
||||||
|
find(user => user.username === username || user.email === username),
|
||||||
|
map(user => {
|
||||||
|
if (!user) {
|
||||||
|
return { headers, url, status: STATUS.UNAUTHORIZED, body: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.password !== password) {
|
||||||
|
const result = {
|
||||||
|
status: STATUS.UNPROCESSABLE_ENTRY,
|
||||||
|
error: { errors: { password: ['The provided password is incorrect.'] } },
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.assign({ headers, url }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = Object.assign({}, user);
|
||||||
|
delete currentUser.password;
|
||||||
|
return { headers, url, status: STATUS.OK, body: jwt.generate(currentUser) };
|
||||||
|
}),
|
||||||
|
switchMap(response => reqInfo.utils.createResponse$(() => response))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(reqInfo: RequestInfo) {
|
||||||
|
const { headers, url } = reqInfo;
|
||||||
|
const user = jwt.getUser(reqInfo.req as HttpRequest<any>);
|
||||||
|
const result = user
|
||||||
|
? { status: STATUS.OK, body: jwt.generate(user) }
|
||||||
|
: { status: STATUS.UNAUTHORIZED, body: {} };
|
||||||
|
const response = Object.assign({ headers, url }, result);
|
||||||
|
|
||||||
|
return reqInfo.utils.createResponse$(() => response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private logout(reqInfo: RequestInfo) {
|
||||||
|
const { headers, url } = reqInfo;
|
||||||
|
const response = { headers, url, status: STATUS.OK, body: {} };
|
||||||
|
|
||||||
|
return reqInfo.utils.createResponse$(() => response);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
front/app/src/app/shared/index.ts
Normal file
12
front/app/src/app/shared/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Module
|
||||||
|
export * from './shared.module';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
export * from './services/directionality.service';
|
||||||
|
export * from './services/message.service';
|
||||||
|
export * from './services/storage.service';
|
||||||
|
export * from './services/paginator-i18n.service';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
export * from './utils/colors';
|
||||||
|
export * from './utils/icons';
|
||||||
10
front/app/src/app/shared/pipes/safe-url.pipe.ts
Normal file
10
front/app/src/app/shared/pipes/safe-url.pipe.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
import { DomSanitizer } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
@Pipe({ name: 'safeUrl' })
|
||||||
|
export class SafeUrlPipe implements PipeTransform {
|
||||||
|
constructor(private sanitizer: DomSanitizer) {}
|
||||||
|
transform(url: string) {
|
||||||
|
return this.sanitizer.bypassSecurityTrustResourceUrl(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user