diff --git a/Dockerfile b/Dockerfile index a3851616acdbb50ef12377d640803b75ad6f7f12..e441522eaa3c42fcc3a37fb1630c76a7ac5ddeb2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,9 @@ RUN npm install # Copy the project COPY . /app +# Launch postinstall script (to include crypto module) +RUN npm run postinstall + ARG conf # Building the Angular app /dist diff --git a/config/config-dev.json b/config/config-dev.json index e154cc463bc6600168812652f7d706e0b587c0fb..adf822d5cb7103ae1e517c14ba05e845c154d0c4 100644 --- a/config/config-dev.json +++ b/config/config-dev.json @@ -1,11 +1,17 @@ { "organizations": { - "url": "https://kong-dev.alpha.grandlyon.com/organizations/" + "url": "https://kong-dev.alpha.grandlyon.com/organizations/organizations/" }, "resources": { "url": "https://kong-dev.alpha.grandlyon.com/resources/" }, "mediaLibrary": { "url": "https://kong-dev.alpha.grandlyon.com/media-library/" + }, + "authentication": { + "url": "https://kong-dev.alpha.grandlyon.com/authentication/" + }, + "middlewareLegacyAuth": { + "url": "https://kong-dev.alpha.grandlyon.com/middleware-legacy/" } } diff --git a/config/config-rec.json b/config/config-rec.json index 99666bacf0611bdb0dc575d1877ca8fd1161b41f..804b6b67d735472bc7d026d357d0ff410c63004f 100644 --- a/config/config-rec.json +++ b/config/config-rec.json @@ -1,11 +1,17 @@ { "organizations": { - "url": "https://kong-rec.alpha.grandlyon.com/organizations/" + "url": "https://kong-rec.alpha.grandlyon.com/organizations/organizations/" }, "resources": { "url": "https://kong-rec.alpha.grandlyon.com/resources/" }, "mediaLibrary": { "url": "https://kong-rec.alpha.grandlyon.com/media-library/" + }, + "authentication": { + "url": "https://kong-rec.alpha.grandlyon.com/authentication/" + }, + "middlewareLegacyAuth": { + "url": "https://kong-rec.alpha.grandlyon.com/middleware-legacy/" } } diff --git a/package-lock.json b/package-lock.json index 4880633268b69e163a910ab76ab2c76cce10bede..ed6e35f5b5e17cc2a4b35f252a2b2989200ab5e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "admin-gui", - "version": "0.0.0", + "version": "1.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1669,7 +1669,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dev": true, "requires": { "safer-buffer": "~2.1.0" } @@ -7197,6 +7196,14 @@ "semver": "^5.3.0" } }, + "node-rsa": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.0.5.tgz", + "integrity": "sha512-9o51yfV167CtQANnuAf+5owNs7aIMsAKVLhNaKuRxihsUUnfoBMN5OTVOK/2mHSOWaWq9zZBiRM3bHORbTZqrg==", + "requires": { + "asn1": "^0.2.4" + } + }, "node-sass": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.11.0.tgz", @@ -8862,8 +8869,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sass-graph": { "version": "2.2.4", diff --git a/package.json b/package.json index d9459cdc73c1364adc05fc7b717eda481ce7812f..306f117e44ada4f9e28144866dbb13ce4f9a2f27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "admin-gui", - "version": "1.0.0", + "version": "1.1.0", "scripts": { "ng": "ng", "start": "ng serve", @@ -9,7 +9,8 @@ "build:prod": "node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng build --configuration=production", "test": "ng test", "lint": "ng lint", - "e2e": "ng e2e" + "e2e": "ng e2e", + "postinstall": "node patch.js" }, "private": true, "dependencies": { @@ -25,6 +26,7 @@ "@angular/router": "^7.2.4", "bulma": "^0.7.4", "core-js": "^2.6.4", + "node-rsa": "^1.0.5", "rxjs": "^6.4.0", "rxjs-tslint": "^0.1.7", "sass-recursive-map-merge": "^1.0.1", diff --git a/patch.js b/patch.js new file mode 100644 index 0000000000000000000000000000000000000000..909dfb2e373737981d83371f24fd67b4245e44f5 --- /dev/null +++ b/patch.js @@ -0,0 +1,13 @@ +const fs = require('fs'); +const f = 'node_modules/@angular-devkit/build-angular/src/angular-cli-files/models/webpack-configs/browser.js'; + +fs.readFile(f, 'utf8', function (err,data) { + if (err) { + return console.log(err); + } + var result = data.replace(/node: false/g, 'node: {crypto: true}'); + + fs.writeFile(f, result, 'utf8', function (err) { + if (err) return console.log(err); + }); +}); \ No newline at end of file diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 9b843b6f527b5aebbae62eec91003795da9b875f..2b577c9af1411a17305f79881a6ec74139773d22 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -8,6 +8,18 @@ import { RouterModule } from '@angular/router'; import { AppRoutingModule } from './app.routing.module'; import { AppServices, AppConfigService } from './services'; import { AppComponents } from './components'; +import { UserModule } from './user/user.module'; +import { UserService } from './user/services'; + +// Function used by APP_INITIALIZER before the app start: init user info / statut (expect a promise) +export function initUserService(authService: UserService) { + return (): Promise<any> => { + return new Promise((resolve, reject) => { + authService.initializeService(); + resolve(); + }); + }; +} export function initAppConfig(appConfigService: AppConfigService) { return (): Promise<any> => { @@ -31,9 +43,16 @@ export function initAppConfig(appConfigService: AppConfigService) { BrowserAnimationsModule, HttpClientModule, ReactiveFormsModule, + UserModule, ], providers: [ ...AppServices, + { + provide: APP_INITIALIZER, + useFactory: initUserService, + deps: [UserService], + multi: true, + }, { provide: APP_INITIALIZER, useFactory: initAppConfig, diff --git a/src/app/app.routing.module.ts b/src/app/app.routing.module.ts index 2e229688a10fc14059435ecf98a68a990b6f7dc4..e573d0832074c5a3e51e76bd3ea2ddd67b912ba3 100644 --- a/src/app/app.routing.module.ts +++ b/src/app/app.routing.module.ts @@ -8,11 +8,13 @@ import { ResourcesComponent } from './components/resources/list/resources.compon import { ResourceFormComponent } from './components/resources/edit/resource-form.component'; import { ResourceDetailComponent } from './components/resources/detail/resource-detail.component'; import { FormatsComponent, FormatDetailComponent, FormatFormComponent } from './components'; +import { AuthenticatedGuard } from './user/guards/authenticated.guard'; const appRoutes: Routes = [ { path: '', component: WelcomeComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Bienvenue', }, @@ -20,6 +22,7 @@ const appRoutes: Routes = [ { path: 'organizations', component: OrganizationsComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Organisations', }, @@ -27,6 +30,7 @@ const appRoutes: Routes = [ { path: 'organizations/new', component: OrganizationFormComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Nouvelle organisation', }, @@ -34,6 +38,7 @@ const appRoutes: Routes = [ { path: 'organizations/:id/edit', component: OrganizationFormComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Modifier l\'organisation', }, @@ -41,6 +46,7 @@ const appRoutes: Routes = [ { path: 'organizations/:id', component: OrganizationDetailComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Organisation', }, @@ -48,6 +54,7 @@ const appRoutes: Routes = [ { path: 'resources', component: ResourcesComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Ressources', }, @@ -55,6 +62,7 @@ const appRoutes: Routes = [ { path: 'resources/new', component: ResourceFormComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Nouvelle ressource', }, @@ -62,6 +70,7 @@ const appRoutes: Routes = [ { path: 'resources/:id/edit', component: ResourceFormComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Modifier la ressource', }, @@ -69,6 +78,7 @@ const appRoutes: Routes = [ { path: 'resources/:id', component: ResourceDetailComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Ressource', }, @@ -76,6 +86,7 @@ const appRoutes: Routes = [ { path: 'formats', component: FormatsComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Formats', }, @@ -83,6 +94,7 @@ const appRoutes: Routes = [ { path: 'formats/new', component: FormatFormComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Nouveau format', }, @@ -90,6 +102,7 @@ const appRoutes: Routes = [ { path: 'formats/:id/edit', component: FormatFormComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Modifier le format', }, @@ -97,6 +110,7 @@ const appRoutes: Routes = [ { path: 'formats/:id', component: FormatDetailComponent, + canActivate: [AuthenticatedGuard], data: { title: 'Format', }, @@ -110,7 +124,5 @@ const appRoutes: Routes = [ exports: [ RouterModule, ], - providers: [ - ], }) -export class AppRoutingModule {} +export class AppRoutingModule { } diff --git a/src/app/components/welcome/welcome.component.html b/src/app/components/welcome/welcome.component.html index 7cbaf54bb520750aec4db7993745dc014868579f..099c771bdebfe62ef89f8357e6a8805c8e2c0cbf 100644 --- a/src/app/components/welcome/welcome.component.html +++ b/src/app/components/welcome/welcome.component.html @@ -1,3 +1,7 @@ <section> - <h2 class="has-text-centered">Services Backend de la plateforme Data Grand Lyon</h2> -</section> + <div class="user-info-container"> + <p class="welcome-back-message">Content de vous revoir {{ loggedInUserFullname }}</p> + + <button class="button button-gl" (click)="loggout()">Deconnexion</button> + </div> +</section> \ No newline at end of file diff --git a/src/app/components/welcome/welcome.component.scss b/src/app/components/welcome/welcome.component.scss index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..13ea27f83c932252bc787143512353bdbdc0272b 100644 --- a/src/app/components/welcome/welcome.component.scss +++ b/src/app/components/welcome/welcome.component.scss @@ -0,0 +1,9 @@ +.user-info-container { + text-align: center; + margin-top: 2rem; +} + +.welcome-back-message { + font-size: 1.5rem; + margin-bottom: 1rem; +} diff --git a/src/app/components/welcome/welcome.component.ts b/src/app/components/welcome/welcome.component.ts index 956948c2a3778667913f456f6c2a880408987786..4d118c0dc02557dee83bf5f83b1159c360b0b731 100644 --- a/src/app/components/welcome/welcome.component.ts +++ b/src/app/components/welcome/welcome.component.ts @@ -1,4 +1,7 @@ import { Component, OnInit } from '@angular/core'; +import { UserService } from '../../user/services'; +import { Router } from '@angular/router'; +import { NotificationService } from '../../services'; @Component({ selector: 'app-welcome', @@ -8,8 +11,20 @@ import { Component, OnInit } from '@angular/core'; export class WelcomeComponent implements OnInit { - constructor() {} + constructor( + private _userService: UserService, + private _router: Router, + ) { } - ngOnInit(): void {} + ngOnInit(): void { } + + get loggedInUserFullname() { + return `${this._userService.user.firstName} ${this._userService.user.lastName}`; + } + + loggout() { + this._userService.resetAuth(); + this._router.navigate(['/login']); + } } diff --git a/src/app/services/app-config.service.ts b/src/app/services/app-config.service.ts index 66e9fd31fad98efbde56154a7c9f346e8cf1dcb4..c8fb34321e489120210a882aa6c17b576b2fe8ae 100644 --- a/src/app/services/app-config.service.ts +++ b/src/app/services/app-config.service.ts @@ -10,6 +10,12 @@ export class AppConfig { mediaLibrary: { url: string; }; + authentication: { + url: string; + }; + middlewareLegacyAuth: { + url: string; + }; } export let APP_CONFIG: AppConfig; diff --git a/src/app/services/format.service.ts b/src/app/services/format.service.ts index 722f25a8a0188f9c85742e343b50e66f59a167c7..bd2b12a3a6c1c5b27570b675282159bf9922bfa4 100644 --- a/src/app/services/format.service.ts +++ b/src/app/services/format.service.ts @@ -58,18 +58,18 @@ export class FormatService { } delete(id) { - return this._httpClient.delete(this.formatServiceUrl + id); + return this._httpClient.delete(this.formatServiceUrl + id, { withCredentials: true }); } replaceOrCreate(data): Observable<Format> { if (data.id) { - return this._httpClient.put<IFormat>(this.formatServiceUrl + data.id, data).pipe( + return this._httpClient.put<IFormat>(this.formatServiceUrl + data.id, data, { withCredentials: true }).pipe( map((response) => { return new Format(response); }), ); } - return this._httpClient.post<IFormat>(this.formatServiceUrl, data).pipe( + return this._httpClient.post<IFormat>(this.formatServiceUrl, data, { withCredentials: true }).pipe( map((response) => { return new Format(response); }), diff --git a/src/app/services/organization.service.ts b/src/app/services/organization.service.ts index d5a82d7ce9d00a5b3a3aeebd765a1a93fca73923..689c7407ef08430af970d1ed7eb7fabde66bab85 100644 --- a/src/app/services/organization.service.ts +++ b/src/app/services/organization.service.ts @@ -50,7 +50,7 @@ export class OrganizationService { } delete(id) { - return this._httpClient.delete(APP_CONFIG.organizations.url + id); + return this._httpClient.delete(APP_CONFIG.organizations.url + id, { withCredentials: true }); } uploadLogoAndSaveOrganization(logoFile, organization) { @@ -66,7 +66,7 @@ export class OrganizationService { const formData = new FormData(); formData.append('file', logoFile); - return this._httpClient.post<string>(`${APP_CONFIG.mediaLibrary.url}media`, formData).pipe( + return this._httpClient.post<string>(`${APP_CONFIG.mediaLibrary.url}media`, formData, { withCredentials: true }).pipe( map((response) => { return response; }), @@ -75,14 +75,14 @@ export class OrganizationService { replaceOrCreate(data: Organization): Observable<Organization> { if (data.id) { - return this._httpClient.put<IOrganization>(APP_CONFIG.organizations.url + data.id, data).pipe( + return this._httpClient.put<IOrganization>(APP_CONFIG.organizations.url + data.id, data, { withCredentials: true }).pipe( map((response) => { return new Organization(response); }), ); } - return this._httpClient.post<IOrganization>(APP_CONFIG.organizations.url, data).pipe( + return this._httpClient.post<IOrganization>(APP_CONFIG.organizations.url, data, { withCredentials: true }).pipe( map((response) => { return new Organization(response); }), diff --git a/src/app/services/resource.service.ts b/src/app/services/resource.service.ts index b598eb31653bfe0f7fd8005c2d93a78bb996abf5..a46b3d35be2e366d01c26696c4b87fa7ab1db43f 100644 --- a/src/app/services/resource.service.ts +++ b/src/app/services/resource.service.ts @@ -52,18 +52,18 @@ export class ResourceService { } delete(id) { - return this._httpClient.delete(this.resourceServiceUrl + id); + return this._httpClient.delete(this.resourceServiceUrl + id, { withCredentials: true }); } replaceOrCreate(data): Observable<Resource> { if (data.id) { - return this._httpClient.put<IResource>(this.resourceServiceUrl + data.id, data).pipe( + return this._httpClient.put<IResource>(this.resourceServiceUrl + data.id, data, { withCredentials: true }).pipe( map((response) => { return new Resource(response); }), ); } - return this._httpClient.post<IResource>(this.resourceServiceUrl, data).pipe( + return this._httpClient.post<IResource>(this.resourceServiceUrl, data, { withCredentials: true }).pipe( map((response) => { return new Resource(response); }), @@ -73,7 +73,7 @@ export class ResourceService { createResourceFormats(resourceId: string, resourceFormats: IResourceFormat[]): Observable<IResourceFormat> { return from(resourceFormats).pipe( concatMap((rf) => { - return this._httpClient.post<IResourceFormat>(`${this.resourceServiceUrl}${resourceId}/formats`, rf); + return this._httpClient.post<IResourceFormat>(`${this.resourceServiceUrl}${resourceId}/formats`, rf, { withCredentials: true }); }), ); } @@ -81,7 +81,11 @@ export class ResourceService { updateResourceFormats(resourceId: string, resourceFormats: IResourceFormat[]) { return from(resourceFormats).pipe( concatMap((rf) => { - return this._httpClient.put<IResourceFormat>(`${this.resourceServiceUrl}${resourceId}/formats/${rf.id}`, rf); + return this._httpClient.put<IResourceFormat>( + `${this.resourceServiceUrl}${resourceId}/formats/${rf.id}`, + rf, + { withCredentials: true }, + ); }), ); } @@ -89,7 +93,10 @@ export class ResourceService { deleteResourceFormats(resourceId: string, resourceFormatsId: string[]) { return from(resourceFormatsId).pipe( concatMap((rfId) => { - return this._httpClient.delete<IResourceFormat>(`${this.resourceServiceUrl}${resourceId}/formats/${rfId}`); + return this._httpClient.delete<IResourceFormat>( + `${this.resourceServiceUrl}${resourceId}/formats/${rfId}`, + { withCredentials: true }, + ); }), ); } diff --git a/src/app/user/components/index.ts b/src/app/user/components/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e17c0f5ba56dc2c38e8c16548e2044d3f180da61 --- /dev/null +++ b/src/app/user/components/index.ts @@ -0,0 +1,8 @@ +import { LoginComponent } from './login/login.component'; + +export { LoginComponent }; + +// tslint:disable-next-line:variable-name +export const UserComponents = [ + LoginComponent, +]; diff --git a/src/app/user/components/login/login.component.html b/src/app/user/components/login/login.component.html new file mode 100644 index 0000000000000000000000000000000000000000..9879ce5af188eff8f3b9d77c8c05f7174cdbfd8b --- /dev/null +++ b/src/app/user/components/login/login.component.html @@ -0,0 +1,56 @@ +<h1 class="has-text-centered" i18n="@@login.signIn">Sign In</h1> + +<div class="login-form-container"> + <form [formGroup]="form" (ngSubmit)="login()"> + <div class="columns"> + <div class="column is-8-touch is-offset-2-touch is-6 is-offset-3"> + <div class="notification is-danger" *ngIf="errorStatus !== null"> + <button class="delete" (click)="closeErrorMessage()"></button> + <span *ngIf="errorStatus === 400; else genericErrorTemplate" i18n="@@login.incorrectCredentials">Your + credentials are not correct.</span> + <ng-template #genericErrorTemplate><span i18n="@@login.internalError">Something went wrong, please try again + later.</span></ng-template> + </div> + + <div class="field"> + <label class="label" for="username">Email:</label> + <p class="control has-icons-left"> + <input id="username" class="input" type="email" formControlName="username" + [ngClass]="{'is-danger': fieldIsInvalid('username'), 'is-success': fieldIsValid('username')}"> + <span class="icon is-small is-left"> + <i class="fas fa-envelope"></i> + </span> + </p> + <div class="form-incorrect-field-message" *ngIf="fieldIsInvalid('username')"> + <div *ngIf="username.errors['required']" i18n="@@login.emailRequired"> + Email is required. + </div> + <div *ngIf="username.errors['email']" i18n="@@login.incorrectEmail"> + You need to provide a valid email. + </div> + </div> + </div> + + <div class="field"> + <label class="label" for="password" i18n="@@login.password">Password:</label> + <p class="control has-icons-left"> + <input id="password" class="input" type="password" formControlName="password" + [ngClass]="{'is-danger': fieldIsInvalid('password'), 'is-success': fieldIsValid('password')}"> + <span class="icon is-small is-left"> + <i class="fas fa-lock"></i> + </span> + </p> + <div *ngIf="fieldIsInvalid('password')" class="form-incorrect-field-message"> + <div *ngIf="password.errors['required']" i18n="@@login.passwordRequired"> + Password is required. + </div> + </div> + </div> + <div class="has-text-centered button-wrapper"> + <button class="button button-gl sign-in-btn" type="submit" [ngClass]="{'is-loading': formDisabled}" + [disabled]="form.invalid || formDisabled" i18n="@@login.signInBtn">Sign In</button> + </div> + </div> + </div> + </form> +</div> \ No newline at end of file diff --git a/src/app/user/components/login/login.component.scss b/src/app/user/components/login/login.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..9d2018c16e0f25a6bff458d4dbef43df82b806ba --- /dev/null +++ b/src/app/user/components/login/login.component.scss @@ -0,0 +1,71 @@ +h1 { + font-size: 2rem; + margin: 0; + padding: 1.25rem; + background-color: white; +} + +.login-form-container { + background-color: white; + padding: 0 1rem 1rem 1rem; + margin: 1.5rem; + + .columns:first-of-type { + margin-top: 0; + } +} + +.notification { + padding: 0.5rem 1.75rem 0.5rem 0.75rem; +} + +.button-wrapper { + margin-top: 1rem; + display: flex; + align-items: center; + justify-content: center; + + .sign-in-btn { + margin-right: 1rem; + } +} + +.inline { + display: inline-block; + padding: 0 0.5rem; + + a { + img { + max-height: 100%; + margin: 0.5rem 0.5rem; + } + } +} + +.no-account-info { + margin-bottom: 1rem; + display: block +} + +// Form +label { + font-size: 1rem; + font-weight: normal; + font-style: normal; + + &:hover { + cursor: pointer; + } +} + +label .required-field { + color: #F72F2F; + padding-left: 0.25rem; +} + +.form-incorrect-field-message { + color: #333744; + font-style: italic; + font-size: 0.875rem; + margin-top: 0.2rem; +} diff --git a/src/app/user/components/login/login.component.spec.ts b/src/app/user/components/login/login.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7336d9ae5d62d9dff254c5934a3c8546ccaa4df2 --- /dev/null +++ b/src/app/user/components/login/login.component.spec.ts @@ -0,0 +1,48 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { UserService } from '../../services'; + +export class UserServiceMock { + + constructor() { } + + get userIsSignedIn() { + return false; + } + +} + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture<LoginComponent>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + RouterTestingModule, + ], + declarations: [LoginComponent], + providers: [ + { + provide: UserService, + useClass: UserServiceMock, + }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/user/components/login/login.component.ts b/src/app/user/components/login/login.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..97a973cece6c5641a287258eba65240ee532ace7 --- /dev/null +++ b/src/app/user/components/login/login.component.ts @@ -0,0 +1,73 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { UserService } from '../../services'; + +@Component({ + selector: 'app-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'], +}) +export class LoginComponent implements OnInit { + + form: FormGroup; + errorStatus: number = null; + + constructor( + private _fb: FormBuilder, + private _userService: UserService, + private _router: Router, + ) { + + this.form = this._fb.group({ + username: ['', [Validators.required, Validators.email]], + password: ['', Validators.required], + }); + } + + ngOnInit() { } + + login() { + if (this.form.valid) { + this.form.disable(); + this._userService.login(this.form.value).subscribe( + (res) => { + this.form.enable(); + this.errorStatus = null; + this._router.navigate(['/']); + }, + (err) => { + if (err instanceof HttpErrorResponse) { + this.errorStatus = err.status; + } else { + this.errorStatus = 500; + } + this.form.enable(); + }, + ); + } else { + this.errorStatus = 400; + } + } + + closeErrorMessage() { + this.errorStatus = null; + } + + fieldIsValid(field: string) { + return (this.form.get(field).touched) && this.form.get(field).valid; + } + + fieldIsInvalid(field: string) { + return (this.form.get(field).touched) && this.form.get(field).invalid; + } + + get username() { return this.form.get('username'); } + get password() { return this.form.get('password'); } + + get formDisabled() { + return this.form.disabled; + } + +} diff --git a/src/app/user/guards/authenticated.guard.ts b/src/app/user/guards/authenticated.guard.ts new file mode 100644 index 0000000000000000000000000000000000000000..80a52ae68aa2c91ab11cd7970bfbfae45cf91e07 --- /dev/null +++ b/src/app/user/guards/authenticated.guard.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { Router, CanActivate } from '@angular/router'; +import { UserService } from '../services'; + +@Injectable() +export class AuthenticatedGuard implements CanActivate { + + constructor(public _userService: UserService, public router: Router) { } + + canActivate(): boolean { + if (!this._userService.userIsSignedIn) { + this.router.navigate(['/login']); + return false; + } + return true; + } +} diff --git a/src/app/user/guards/index.ts b/src/app/user/guards/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf44fd86f547bd0e0d759d623bd5788fc0f22240 --- /dev/null +++ b/src/app/user/guards/index.ts @@ -0,0 +1,8 @@ +import { AuthenticatedGuard } from './authenticated.guard'; +import { UnauthenticatedGuard } from './unauthenticated.guard'; + +// tslint:disable-next-line:variable-name +export const UserGuards = [ + AuthenticatedGuard, + UnauthenticatedGuard, +]; diff --git a/src/app/user/guards/unauthenticated.guard.ts b/src/app/user/guards/unauthenticated.guard.ts new file mode 100644 index 0000000000000000000000000000000000000000..e41261c1340f3f4073c371e67ed58b295dc10299 --- /dev/null +++ b/src/app/user/guards/unauthenticated.guard.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { Router, CanActivate } from '@angular/router'; +import { UserService } from '../services'; +import { NotificationService } from '../../services'; + +@Injectable() +export class UnauthenticatedGuard implements CanActivate { + + constructor( + private _userService: UserService, + private _router: Router, + private _notificationService: NotificationService, + ) { } + + // This guard is used to restrict the access to unauthenticated user on particular routes + // example: login, signup, resetPassword... + // It also notify the user that he is already logged in + canActivate(): boolean { + if (this._userService.userIsSignedIn) { + this._router.navigate(['/']); + this._notificationService.notify({ + type: 'warning', + message: 'Vous ne pouvez pas accéder à cette page lorsque vous êtes déjà authentifié.', + }); + return false; + } + return true; + } +} diff --git a/src/app/user/interceptors/auth-interceptor.ts b/src/app/user/interceptors/auth-interceptor.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f8f113eaf85d5183915cb387743d1faed7300e5 --- /dev/null +++ b/src/app/user/interceptors/auth-interceptor.ts @@ -0,0 +1,23 @@ +import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + + intercept( + req: HttpRequest<any>, + next: HttpHandler, + ): Observable<HttpEvent<any>> { + const xsrfToken = localStorage.getItem('xsrfToken'); + let request = req; + + // && req.url.includes('https://data-intothesky.alpha.grandlyon.com/authentication/api/logout' + if (xsrfToken) { + request = req.clone({ + headers: req.headers.set('x-xsrf-token', xsrfToken), + }); + } + return next.handle(request); + } +} diff --git a/src/app/user/models/index.ts b/src/app/user/models/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4841469cfaaabda156dbf83a13accd4c3df551f1 --- /dev/null +++ b/src/app/user/models/index.ts @@ -0,0 +1,8 @@ +import { + User, ILoginResponse, ICreateAccountForm, LegacyAccount, IUserInfo, PasswordUpdateForm, + IPasswordForgottenForm, +} from './user.model'; + +export { + IUserInfo, PasswordUpdateForm, User, ILoginResponse, ICreateAccountForm, LegacyAccount, IPasswordForgottenForm, +}; diff --git a/src/app/user/models/user.model.ts b/src/app/user/models/user.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..cbb42c8d96ecbd18e7ba98b0a6ad1a4ddffc73c8 --- /dev/null +++ b/src/app/user/models/user.model.ts @@ -0,0 +1,120 @@ +export class User { + firstName: string; + lastName: string; + email: string; + username: string; + + // payload is the decrypted payload of the JWT token + constructor(payload) { + if (payload) { + this.firstName = payload.firstName; + this.lastName = payload.lastName; + this.email = payload.email; + this.username = payload.username; + } + } +} + +export interface ILoginResponse { + token: string; +} + +export interface ICreateAccountForm { + firstName: string; + lastName: string; + email: string; + emailConfirmation: string; + password: string; + passwordConfirmation: string; + acceptMessages: boolean; + entreprise: string; + address: string; + zipcode: string; + city: string; + country: string; + // cgu: boolean; + consent: boolean; +} + +export class LegacyAccount { + firstName: string; + lastName: string; + username: string; + email: string; + password: string; + acceptMessages: boolean; + entreprise: string; + address: string; + zipcode: string; + city: string; + country: string; + + constructor(form: ICreateAccountForm) { + this.firstName = form.firstName; + this.lastName = form.lastName; + this.username = form.email; + this.email = form.email; + this.password = form.password; + this.acceptMessages = form.acceptMessages; + this.entreprise = form.entreprise; + this.address = form.address; + this.zipcode = form.zipcode; + this.city = form.city; + this.country = form.country; + } +} + +export interface IUserInfo { + username: string; + lastName: string; + firstName: string; + geosourceId: number; + entreprise: string; + address: string; + city: string; + country: string; + zipcode: string; + acceptMessages: boolean; + email: string; + consent: boolean; +} + +export class UserInfo { + username: string; + lastName: string; + firstName: string; + entreprise: string; + address: string; + city: string; + country: string; + zipcode: string; + acceptMessages: boolean; + email: string; + + constructor(form: IUserInfo) { + this.firstName = form.firstName; + this.lastName = form.lastName; + this.username = form.email; + this.email = form.email; + this.acceptMessages = form.acceptMessages; + this.entreprise = form.entreprise; + this.address = form.address; + this.zipcode = form.zipcode; + this.city = form.city; + this.country = form.country; + } +} + +export class PasswordUpdateForm { + newPassword: string; + oldPassword: string; + + constructor(newPassword: string, oldPassword: string) { + this.newPassword = newPassword; + this.oldPassword = oldPassword; + } +} + +export interface IPasswordForgottenForm { + email: string; +} diff --git a/src/app/user/services/index.ts b/src/app/user/services/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bade8aea9a865b147bc4a37fc292b782aa4a2f4 --- /dev/null +++ b/src/app/user/services/index.ts @@ -0,0 +1,8 @@ +import { UserService } from '../services/user.service'; + +export { UserService }; + +// tslint:disable-next-line:variable-name +export const UserServices = [ + UserService, +]; diff --git a/src/app/user/services/user.service.ts b/src/app/user/services/user.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..1385b94d5daf2a213f264bad1f9a2e50b972c5e6 --- /dev/null +++ b/src/app/user/services/user.service.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, Subject } from 'rxjs'; +import { IUserInfo, User, ILoginResponse } from '../models'; +import { map, mergeMap, tap } from 'rxjs/operators'; +import * as NodeRSA from 'node-rsa'; +import { APP_CONFIG } from '../../services/app-config.service'; +import { NotificationService } from '../../services'; +import { Router } from '@angular/router'; + +@Injectable() +export class UserService { + + private _user: User = null; + _userStatusChangedSubject: Subject<boolean>; + + constructor( + private _http: HttpClient, + private _notificationService: NotificationService, + ) { + this._userStatusChangedSubject = new Subject<boolean>(); + } + + initializeService() { + this.setUserInfo(); + } + + // Function and helpers allowing the management of the user session (jwt), info... + setSession(authResult): boolean { + let success = false; + if (authResult && authResult.userInfo && authResult.xsrfToken) { + localStorage.setItem('userInfo', JSON.stringify(authResult.userInfo)); + localStorage.setItem('xsrfToken', authResult.xsrfToken); + this.setUserInfo(); + success = true; + this._userStatusChangedSubject.next(true); + } else { + this.resetAuth(); + } + + return success; + } + + setUserInfo() { + const userInfo = JSON.parse(localStorage.getItem('userInfo')); + if (userInfo) { + this._user = new User(userInfo); + } + } + + resetAuth() { + localStorage.removeItem('userInfo'); + localStorage.removeItem('xsrfToken'); + this.logout().subscribe(); + this._user = null; + this._userStatusChangedSubject.next(false); + } + + get user() { + return this._user; + } + + get userIsSignedIn() { + return this._user != null; + } + + get userStatusChanged$(): Observable<boolean> { + return this._userStatusChangedSubject.asObservable(); + } + + // HTTP Calls + login(loginForm): Observable<boolean> { + // Make sure not to mofidy the object passed in parameter (object reference) when encrypting the password + const form = Object.assign({}, loginForm); + return this.getPublicKey().pipe( + tap( + (publicKey) => { + form.password = this.encrypt(form.password, publicKey); + }, + ), + mergeMap(() => { + return this._http.post<ILoginResponse>( + `${APP_CONFIG.authentication.url}login/legacy`, + form, + { withCredentials: true }, + ); + }), + map( + (res) => { + return this.setSession(res); + }, + ), + ); + } + + logout(): Observable<boolean> { + return this._http.get<boolean>(`${APP_CONFIG.authentication.url}logout`, { withCredentials: true }); + } + + getUserInfo(): Observable<IUserInfo> { + return this._http.get<IUserInfo>(`${APP_CONFIG.authentication.url}user`, { withCredentials: true }); + } + + getPublicKey(): Observable<any> { + return this._http.get<any>(`${APP_CONFIG.middlewareLegacyAuth.url}publicKey`).pipe( + map( + (res) => { + return res.publicKey; + }, + ), + ); + } + + /** + * Takes un unencrypted string as params and returns it encrypted with the public key provided by the backend service + */ + encrypt(unencrypted: string, publicKey: string): string { + if (!publicKey) { + throw new Error('Can\'t encrypt without public key'); + } else { + const key = new NodeRSA(publicKey); + const encrypted = key.encrypt(unencrypted, 'base64'); + return encrypted; + } + } +} diff --git a/src/app/user/user-routing.module.ts b/src/app/user/user-routing.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2b714b694423c5c5c0d5938e01c7268416765df --- /dev/null +++ b/src/app/user/user-routing.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { LoginComponent } from './components'; +import { UnauthenticatedGuard } from './guards/unauthenticated.guard'; + +export const routes: Routes = [ + { + path: 'login', + component: LoginComponent, + canActivate: [UnauthenticatedGuard], + data: { + title: 'Connexion', + }, + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class UserRoutingModule { } diff --git a/src/app/user/user.module.ts b/src/app/user/user.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ea52171e0bc3c7ccf513cd4abc1aeeb2fd6a9f5 --- /dev/null +++ b/src/app/user/user.module.ts @@ -0,0 +1,29 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { UserRoutingModule } from './user-routing.module'; +import { UserServices } from './services'; +import { UserComponents } from './components'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { AuthInterceptor } from './interceptors/auth-interceptor'; +import { UserGuards } from './guards'; + +@NgModule({ + imports: [ + CommonModule, + UserRoutingModule, + FormsModule, + ReactiveFormsModule, + ], + providers: [ + ...UserGuards, + ...UserServices, + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true, + }, + ], + declarations: [...UserComponents], +}) +export class UserModule { } diff --git a/src/app/user/validators/password.validator.ts b/src/app/user/validators/password.validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c0107a3cff3c4edd33755fb7a901a561d7754aa --- /dev/null +++ b/src/app/user/validators/password.validator.ts @@ -0,0 +1,34 @@ +import { AbstractControl } from '@angular/forms'; + +const passwordBlackList = ['grandlyon', 'smartdata', 'lyon']; + +// tslint:disable-next-line:function-name +export function ValidatePassword(control: AbstractControl) { + const errors = {}; + let res = null; + if (!new RegExp(/^[\u0020-\u007e]*$/).test(control.value)) { + errors['invalidCharacters'] = true; + } + if (!new RegExp(/[~`!#$%\$\(@\)\^&*\.+=\-\[\]\\';,/{}|\\":<>\?]/).test(control.value)) { + errors['missingSpecialCharacters'] = true; + } + if (!new RegExp(/[A-Z]/).test(control.value)) { + errors['missingUppercasedLetter'] = true; + } + if (!new RegExp(/[a-z]/).test(control.value)) { + errors['missingLowercasedLetter'] = true; + } + if (!new RegExp(/[0-9]/).test(control.value)) { + errors['missingNumber'] = true; + } + if (new RegExp(passwordBlackList.join('|')).test(control.value)) { + errors['forbiddenWord'] = true; + } + + if (errors !== {}) { + res = errors; + } + + return res; + +} diff --git a/src/assets/config/config.json b/src/assets/config/config.json index 43cecd099741db2447135e4a96bc1d43339ac48c..bd7b9118581dd65b61327035dc36e33dd3805322 100644 --- a/src/assets/config/config.json +++ b/src/assets/config/config.json @@ -7,5 +7,11 @@ }, "mediaLibrary": { "url": "http://localhost:3006/" + }, + "authentication": { + "url": "http://localhost:3002/" + }, + "middlewareLegacyAuth": { + "url": "http://localhost:3004/" } } diff --git a/src/styles.scss b/src/styles.scss index 40d762110c62519cfb2acd2506d6c3d54a94ca23..bf73b5d1a06258854bb9c4790a85956889795f62 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -6,6 +6,12 @@ $menu-item-active-background-color: #222222; $tomato-color: #f72f2f; $dark-blue: #333745; +$brand-color: #333744; +$grey-dark-color: #515151; +$grey-light-color: #818080; +$grey-background-color: #F2F2F2; +$grey-super-light-color: #B4B4B4; +$link-color: #1D92FF; $pagination-current-background-color: $tomato-color; $pagination-current-border-color: $tomato-color; @@ -24,32 +30,49 @@ body { line-height: 1em; } -h1, h2, h3, h4, h5, div, span, p, select, input, ul, tbody { +h1, +h2, +h3, +h4, +h5, +div, +span, +p, +select, +input, +ul, +tbody { color: $dark-blue; } -h1, .h1 { +h1, +.h1 { font-size: $size-1; line-height: $size-3; margin-top: $size-4; margin-bottom: $size-4; } -h2, .h2 { +h2, +.h2 { font-size: $size-3; line-height: $size-2; margin-top: $size-4; margin-bottom: $size-4; } -h3, .h3 { +h3, +.h3 { font-size: $size-4; line-height: $size-3; margin-top: $size-4; margin-bottom: $size-4; } -h4, .h4, h5, .h5 { +h4, +.h4, +h5, +.h5 { font-size: $size-5; line-height: $size-5; margin-top: $size-4; @@ -72,18 +95,22 @@ section { color: $tomato-color; } -input.ng-invalid:not(form).ng-dirty, input.ng-invalid:not(form).ng-touched, -textarea.ng-invalid:not(form).ng-dirty, textarea.ng-invalid:not(form).ng-touched { - border-left: 3px solid $tomato-color; /* red */ +input.ng-invalid:not(form).ng-dirty, +input.ng-invalid:not(form).ng-touched, +textarea.ng-invalid:not(form).ng-dirty, +textarea.ng-invalid:not(form).ng-touched { + border-left: 3px solid $tomato-color; + /* red */ } .button-gl { - min-width: 7rem; + min-width: 8rem; background: $tomato-color; border-radius: 2px; border-width: 0; font-size: $size-6; color: white; + text-transform: capitalize; line-height: unset; &:hover, @@ -93,8 +120,39 @@ textarea.ng-invalid:not(form).ng-dirty, textarea.ng-invalid:not(form).ng-touched opacity: 0.92; } + &.is-outlined { + border: 1px solid $grey-super-light-color; + color: $grey-light-color; + background: transparent; + + &:hover, + &:focus { + border-color: $tomato-color; + box-shadow: none; + } + + &:disabled { + color: $grey-light-color; + background-color: transparent; + opacity: 0.38; + border-color: $grey-super-light-color; + } + } + &:disabled { background: $tomato-color; opacity: 0.38; } -} \ No newline at end of file + + &.is-flat { + background: transparent; + color: $brand-color; + font-weight: bold; + + &:hover, + &:focus { + box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.16); + background-color: #e8ecef; + } + } +}