ソースを参照

feat(admin-ui): Set up basic state, auth & data access infrastructure

Michael Bromley 7 年 前
コミット
9f32c02a8e
42 ファイル変更966 行追加74 行削除
  1. 1 0
      admin-ui/package.json
  2. 1 30
      admin-ui/src/app/app.component.html
  3. 8 24
      admin-ui/src/app/app.component.ts
  4. 1 0
      admin-ui/src/app/app.config.ts
  5. 3 0
      admin-ui/src/app/app.module.ts
  6. 19 0
      admin-ui/src/app/app.routes.ts
  7. 10 0
      admin-ui/src/app/common/types/response.ts
  8. 10 0
      admin-ui/src/app/common/utilities/common.ts
  9. 27 0
      admin-ui/src/app/core/components/app-shell/app-shell.component.html
  10. 0 0
      admin-ui/src/app/core/components/app-shell/app-shell.component.scss
  11. 25 0
      admin-ui/src/app/core/components/app-shell/app-shell.component.spec.ts
  12. 15 0
      admin-ui/src/app/core/components/app-shell/app-shell.component.ts
  13. 12 1
      admin-ui/src/app/core/core.module.ts
  14. 61 0
      admin-ui/src/app/core/providers/data/base-data.service.ts
  15. 16 0
      admin-ui/src/app/core/providers/data/data.service.ts
  16. 32 0
      admin-ui/src/app/core/providers/data/product-data.service.ts
  17. 21 0
      admin-ui/src/app/core/providers/data/user-data.service.ts
  18. 35 0
      admin-ui/src/app/core/providers/guard/auth.guard.ts
  19. 46 0
      admin-ui/src/app/core/providers/local-storage/local-storage.service.ts
  20. 58 0
      admin-ui/src/app/dashboard/components/dashboard/dashboard.component.html
  21. 0 0
      admin-ui/src/app/dashboard/components/dashboard/dashboard.component.scss
  22. 25 0
      admin-ui/src/app/dashboard/components/dashboard/dashboard.component.spec.ts
  23. 21 0
      admin-ui/src/app/dashboard/components/dashboard/dashboard.component.ts
  24. 15 0
      admin-ui/src/app/dashboard/dashboard.module.ts
  25. 11 0
      admin-ui/src/app/dashboard/dashboard.routes.ts
  26. 32 0
      admin-ui/src/app/login/components/login/login.component.html
  27. 0 0
      admin-ui/src/app/login/components/login/login.component.scss
  28. 25 0
      admin-ui/src/app/login/components/login/login.component.spec.ts
  29. 35 0
      admin-ui/src/app/login/components/login/login.component.ts
  30. 18 0
      admin-ui/src/app/login/login.module.ts
  31. 11 0
      admin-ui/src/app/login/login.routes.ts
  32. 13 6
      admin-ui/src/app/shared/shared.module.ts
  33. 8 0
      admin-ui/src/app/state/app-state.ts
  34. 24 0
      admin-ui/src/app/state/handle-error.ts
  35. 86 0
      admin-ui/src/app/state/state-store.service.ts
  36. 8 6
      admin-ui/src/app/state/state.module.ts
  37. 60 0
      admin-ui/src/app/state/user/user-actions.ts
  38. 68 0
      admin-ui/src/app/state/user/user-reducer.spec.ts
  39. 41 0
      admin-ui/src/app/state/user/user-reducer.ts
  40. 60 0
      admin-ui/src/app/state/user/user-state.ts
  41. 0 7
      admin-ui/tsconfig.json
  42. 4 0
      admin-ui/yarn.lock

+ 1 - 0
admin-ui/package.json

@@ -33,6 +33,7 @@
     "core-js": "^2.5.4",
     "graphql": "^0.13.2",
     "graphql-tag": "^2.9.2",
+    "immer": "^1.3.1",
     "rxjs": "^6.0.0",
     "rxjs-compat": "^6.2.1",
     "zone.js": "^0.8.26"

+ 1 - 30
admin-ui/src/app/app.component.html

@@ -1,30 +1 @@
-<!--The content below is only a placeholder and can be replaced.-->
-<div style="text-align:center">
-  <h1>
-    Welcome to {{ title }}!
-  </h1>
-  <clr-icon shape="info-circle" size="12"></clr-icon>
-  <clr-icon shape="info-circle" size="16"></clr-icon>
-  <clr-icon shape="info-circle" size="36"></clr-icon>
-  <clr-icon shape="info-circle" size="48"></clr-icon>
-  <clr-icon shape="info-circle" size="64"></clr-icon>
-  <clr-icon shape="info-circle" size="72"></clr-icon>
-
-
-  <clr-datagrid>
-    <clr-dg-column>Product ID</clr-dg-column>
-    <clr-dg-column>Name</clr-dg-column>
-    <clr-dg-column>Slug</clr-dg-column>
-    <clr-dg-column>Description</clr-dg-column>
-
-    <clr-dg-row *ngFor="let product of products">
-      <clr-dg-cell>{{product.id}}</clr-dg-cell>
-      <clr-dg-cell>{{product.name}}</clr-dg-cell>
-      <clr-dg-cell>{{product.slug}}</clr-dg-cell>
-      <clr-dg-cell>{{product.description}}</clr-dg-cell>
-    </clr-dg-row>
-
-    <clr-dg-footer>{{products.length}} users</clr-dg-footer>
-  </clr-datagrid>
-
-</div>
+<router-outlet></router-outlet>

+ 8 - 24
admin-ui/src/app/app.component.ts

@@ -1,37 +1,21 @@
-import { Component } from '@angular/core';
+import { Component, OnInit } from '@angular/core';
 import { Apollo } from 'apollo-angular';
 import gql from 'graphql-tag';
+import { UserActions } from './state/user/user-actions';
 
 @Component({
     selector: 'vdr-root',
     templateUrl: './app.component.html',
     styleUrls: ['./app.component.scss'],
 })
-export class AppComponent {
+export class AppComponent implements OnInit {
     title = 'Vendure';
     products: any[] = [];
 
-    constructor(apollo: Apollo) {
-        apollo.query<any>({
-            query: gql`
-                {
-                    products(languageCode: en) {
-                        id
-                        languageCode
-                        name
-                        slug
-                        description
-                        translations {
-                            id
-                            languageCode
-                            name
-                        }
-                    }
-                }
-            `,
-        })
-        .subscribe(result => {
-            this.products = result.data.products;
-        });
+    constructor() {
+    }
+
+    ngOnInit() {
+
     }
 }

+ 1 - 0
admin-ui/src/app/app.config.ts

@@ -0,0 +1 @@
+export const API_URL = 'http://localhost:3000';

+ 3 - 0
admin-ui/src/app/app.module.ts

@@ -1,12 +1,14 @@
 import { HttpClientModule } from '@angular/common/http';
 import { NgModule } from '@angular/core';
 import { BrowserModule } from '@angular/platform-browser';
+import { RouterModule } from '@angular/router';
 import { ClarityModule } from '@clr/angular';
 import { Apollo, ApolloModule } from 'apollo-angular';
 import { HttpLink, HttpLinkModule } from 'apollo-angular-link-http';
 import { InMemoryCache } from 'apollo-cache-inmemory';
 
 import { AppComponent } from './app.component';
+import { routes } from './app.routes';
 import { CoreModule } from './core/core.module';
 
 @NgModule({
@@ -15,6 +17,7 @@ import { CoreModule } from './core/core.module';
     ],
     imports: [
         BrowserModule,
+        RouterModule.forRoot(routes, { useHash: false }),
         CoreModule,
     ],
     providers: [],

+ 19 - 0
admin-ui/src/app/app.routes.ts

@@ -0,0 +1,19 @@
+import { Route } from '@angular/router';
+import { AppShellComponent } from './core/components/app-shell/app-shell.component';
+import { AuthGuard } from './core/providers/guard/auth.guard';
+
+export const routes: Route[] = [
+    { path: 'login', loadChildren: './login/login.module#LoginModule' },
+    {
+        path: '',
+        canActivate: [AuthGuard],
+        component: AppShellComponent,
+        children: [
+            {
+                path: '',
+                pathMatch: 'full',
+                loadChildren: './dashboard/dashboard.module#DashboardModule',
+            },
+        ],
+    },
+];

+ 10 - 0
admin-ui/src/app/common/types/response.ts

@@ -0,0 +1,10 @@
+export interface UserResponse {
+    id: number;
+    identifier: string;
+    roles: string[];
+}
+
+export interface LoginResponse {
+    token: string;
+    user: UserResponse;
+}

+ 10 - 0
admin-ui/src/app/common/utilities/common.ts

@@ -0,0 +1,10 @@
+
+/**
+ * Like assertNever, but at runtime this will be reached due to the way that all actions
+ * are dispatched and piped through all reducers. So this just provides a compile-time
+ * exhaustiveness check for those reducers which use a switch statement over
+ * a discriminated union type of actions.
+ */
+export function reducerCaseNever(x: never, errorMessage?: string): void {
+    // this function intentionally left empty
+}

+ 27 - 0
admin-ui/src/app/core/components/app-shell/app-shell.component.html

@@ -0,0 +1,27 @@
+<div class="main-container">
+    <!--<div class="alert alert-app-level">
+        ALERT
+    </div>-->
+    <header class="header header-6">
+        <div class="branding">
+            <a href="#">Vendure</a>
+        </div>
+        <div class="header-nav">
+            <a href="javascript://" class="active nav-link nav-text">Dashboard</a>
+            <a href="javascript://" class="nav-link nav-text">Orders</a>
+            <a href="javascript://" class="nav-link nav-text">Inventory</a>
+            <a href="javascript://" class="nav-link nav-text">Users</a>
+        </div>
+        <div class="header-actions">
+            <a href="javascript://" class="nav-link nav-icon">
+                <clr-icon shape="cog"></clr-icon>
+            </a>
+        </div>
+    </header>
+    <nav class="subnav">
+
+    </nav>
+    <div class="content-container">
+        <router-outlet></router-outlet>
+    </div>
+</div>

+ 0 - 0
admin-ui/src/app/core/components/app-shell/app-shell.component.scss


+ 25 - 0
admin-ui/src/app/core/components/app-shell/app-shell.component.spec.ts

@@ -0,0 +1,25 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { AppShellComponent } from './app-shell.component';
+
+describe('AppShellComponent', () => {
+  let component: AppShellComponent;
+  let fixture: ComponentFixture<AppShellComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ AppShellComponent ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(AppShellComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 15 - 0
admin-ui/src/app/core/components/app-shell/app-shell.component.ts

@@ -0,0 +1,15 @@
+import { Component, OnInit } from '@angular/core';
+
+@Component({
+  selector: 'vdr-app-shell',
+  templateUrl: './app-shell.component.html',
+  styleUrls: ['./app-shell.component.scss']
+})
+export class AppShellComponent implements OnInit {
+
+  constructor() { }
+
+  ngOnInit() {
+  }
+
+}

+ 12 - 1
admin-ui/src/app/core/core.module.ts

@@ -6,10 +6,16 @@ import { InMemoryCache } from 'apollo-cache-inmemory';
 
 import { SharedModule } from '../shared/shared.module';
 import { APOLLO_NGRX_CACHE, StateModule } from '../state/state.module';
+import { AppShellComponent } from './components/app-shell/app-shell.component';
+import { BaseDataService } from './providers/data/base-data.service';
+import { API_URL } from '../app.config';
+import { LocalStorageService } from './providers/local-storage/local-storage.service';
+import { DataService } from './providers/data/data.service';
+import { AuthGuard } from './providers/guard/auth.guard';
 
 export function createApollo(httpLink: HttpLink, ngrxCache: InMemoryCache) {
   return {
-    link: httpLink.create({uri: 'http://localhost:3000/graphql'}),
+    link: httpLink.create({ uri: `${API_URL}/graphql` }),
     cache: ngrxCache,
   };
 }
@@ -31,6 +37,11 @@ export function createApollo(httpLink: HttpLink, ngrxCache: InMemoryCache) {
             useFactory: createApollo,
             deps: [HttpLink, APOLLO_NGRX_CACHE],
         },
+        BaseDataService,
+        LocalStorageService,
+        DataService,
+        AuthGuard,
     ],
+    declarations: [AppShellComponent],
 })
 export class CoreModule {}

+ 61 - 0
admin-ui/src/app/core/providers/data/base-data.service.ts

@@ -0,0 +1,61 @@
+import { Injectable } from '@angular/core';
+import { Apollo } from 'apollo-angular';
+import { DocumentNode } from 'graphql/language/ast';
+import { Observable } from 'rxjs';
+import { ApolloQueryResult } from 'apollo-client/core/types';
+import { HttpClient } from '@angular/common/http';
+import { API_URL } from '../../../app.config';
+import { map } from 'rxjs/operators';
+import { StateStore } from '../../../state/state-store.service';
+import { LocalStorageService } from '../local-storage/local-storage.service';
+
+@Injectable()
+export class BaseDataService {
+
+    constructor(private apollo: Apollo,
+                private httpClient: HttpClient,
+                private localStorageService: LocalStorageService) {}
+
+    /**
+     * Performs a GraphQL query
+     */
+    query<T, V = Record<string, any>>(query: DocumentNode, variables?: V): Observable<T> {
+        return this.apollo.query<T, V>({
+            query,
+            variables,
+            context: {
+                headers: {
+                    Authorization: this.getAuthHeader(),
+                },
+            },
+        }).pipe(map(result => result.data));
+    }
+
+    /**
+     * Perform REST POST
+     */
+    post(path: string, payload: Record<string, any>): Observable<any> {
+        return this.httpClient.post(`${API_URL}/${path}`, payload, {
+            headers: {
+                Authorization: this.getAuthHeader(),
+            },
+        });
+    }
+
+    /**
+     * Perform REST GET
+     */
+    get(path: string): Observable<any> {
+        return this.httpClient.get(`${API_URL}/${path}`, {
+            headers: {
+                Authorization: this.getAuthHeader(),
+            },
+        });
+    }
+
+    private getAuthHeader(): string {
+        const authToken = this.localStorageService.get('authToken');
+        return `Bearer ${authToken}`;
+    }
+
+}

+ 16 - 0
admin-ui/src/app/core/providers/data/data.service.ts

@@ -0,0 +1,16 @@
+import { Injectable } from '@angular/core';
+import { UserDataService } from './user-data.service';
+import { BaseDataService } from './base-data.service';
+import { ProductDataService } from './product-data.service';
+
+@Injectable()
+export class DataService {
+    user: UserDataService;
+    product: ProductDataService;
+
+    constructor(baseDataService: BaseDataService) {
+        this.user = new UserDataService(baseDataService);
+        this.product = new ProductDataService(baseDataService);
+    }
+
+}

+ 32 - 0
admin-ui/src/app/core/providers/data/product-data.service.ts

@@ -0,0 +1,32 @@
+import { BaseDataService } from './base-data.service';
+import gql from 'graphql-tag';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+export class ProductDataService {
+
+    constructor(private baseDataService: BaseDataService) {}
+
+    getProducts(): Observable<any[]> {
+        const query = gql`
+            {
+                products(languageCode: en) {
+                    id
+                    languageCode
+                    name
+                    slug
+                    description
+                    translations {
+                        id
+                        languageCode
+                        name
+                    }
+                }
+            }
+        `;
+        return this.baseDataService.query<any>(query).pipe(
+            map(data => data.products),
+        );
+    }
+
+}

+ 21 - 0
admin-ui/src/app/core/providers/data/user-data.service.ts

@@ -0,0 +1,21 @@
+import { BaseDataService } from './base-data.service';
+import { Observable } from 'rxjs';
+import { LoginResponse, UserResponse } from '../../../common/types/response';
+
+export class UserDataService {
+
+    constructor(private baseDataService: BaseDataService) {}
+
+    checkLoggedIn(): Observable<UserResponse> {
+        return this.baseDataService.get('auth/me');
+    }
+
+    logIn(username: string, password: string): Observable<LoginResponse> {
+        return this.baseDataService.post('auth/login', {
+            username,
+            password,
+        });
+    }
+
+}
+

+ 35 - 0
admin-ui/src/app/core/providers/guard/auth.guard.ts

@@ -0,0 +1,35 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router';
+import { StateStore } from '../../../state/state-store.service';
+import { flatMap, mergeMap, tap } from 'rxjs/operators';
+import { Observable, of } from 'rxjs';
+import { UserActions } from '../../../state/user/user-actions';
+
+@Injectable()
+export class AuthGuard implements CanActivate {
+
+    constructor(private router: Router,
+                private userActions: UserActions,
+                private store: StateStore) {}
+
+    canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
+
+        return this.store.select(state => state.user.isLoggedIn).pipe(
+            mergeMap(loggedIn => {
+                if (!loggedIn) {
+                    return this.userActions.checkAuth();
+                } else {
+                    return of(true);
+                }
+            }),
+            tap(authenticated => {
+                if (authenticated) {
+                    return true;
+                } else {
+                    this.router.navigate(['/login']);
+                    return false;
+                }
+            }),
+        );
+    }
+}

+ 46 - 0
admin-ui/src/app/core/providers/local-storage/local-storage.service.ts

@@ -0,0 +1,46 @@
+import {Injectable} from '@angular/core';
+
+export type LocalStorageKey = 'authToken';
+const PREFIX = 'vnd_';
+
+/**
+ * Wrapper around the browser's LocalStorage / SessionStorage object, for persisting data to the browser.
+ */
+@Injectable()
+export class LocalStorageService {
+
+    /**
+     * Set a key-value pair in the browser's LocalStorage
+     */
+    public set(key: LocalStorageKey, value: any): void {
+        const keyName = this.keyName(key);
+        localStorage.setItem(keyName, JSON.stringify(value));
+    }
+
+    /**
+     * Set a key-value pair in the browser's SessionStorage
+     */
+    public setForSession(key: LocalStorageKey, value: any): void {
+        const keyName = this.keyName(key);
+        sessionStorage.setItem(keyName, JSON.stringify(value));
+    }
+
+    /**
+     * Get the value of the given key from the SessionStorage or LocalStorage.
+     */
+    public get(key: LocalStorageKey): any {
+        const keyName = this.keyName(key);
+        const item = sessionStorage.getItem(keyName) || localStorage.getItem(keyName);
+        return JSON.parse(item || 'null');
+    }
+
+    public remove(key: LocalStorageKey): void {
+        const keyName = this.keyName(key);
+        sessionStorage.removeItem(keyName);
+        localStorage.removeItem(keyName);
+    }
+
+    private keyName(key: LocalStorageKey): string {
+        return PREFIX + key;
+    }
+}

+ 58 - 0
admin-ui/src/app/dashboard/components/dashboard/dashboard.component.html

@@ -0,0 +1,58 @@
+<h1>Dashboard</h1>
+
+<div class="row">
+    <div class="col-lg-5 col-md-8 col-sm-12 col-xs-12">
+        <div class="card">
+            <div class="card-header">
+                Dashboard Widget 1
+            </div>
+            <div class="card-block">
+                <div class="card-title">
+                    Block
+                </div>
+                <div class="card-text">
+                    ...
+                </div>
+            </div>
+            <div class="card-footer">
+                <button class="btn btn-sm btn-link">Footer Action 1</button>
+                <button class="btn btn-sm btn-link">Footer Action 2</button>
+            </div>
+        </div>
+    </div>
+    <div class="col-lg-5 col-md-8 col-sm-12 col-xs-12">
+        <div class="card">
+            <div class="card-header">
+                Dashboard Widget 2
+            </div>
+            <div class="card-block">
+                <div class="card-title">
+                    Block
+                </div>
+                <div class="card-text">
+                    ...
+                </div>
+            </div>
+            <div class="card-footer">
+                <button class="btn btn-sm btn-link">Footer Action 1</button>
+                <button class="btn btn-sm btn-link">Footer Action 2</button>
+            </div>
+        </div>
+    </div>
+</div>
+
+<clr-datagrid>
+    <clr-dg-column>Product ID</clr-dg-column>
+    <clr-dg-column>Name</clr-dg-column>
+    <clr-dg-column>Slug</clr-dg-column>
+    <clr-dg-column>Description</clr-dg-column>
+
+    <clr-dg-row *ngFor="let product of products$ | async">
+        <clr-dg-cell>{{ product.id }}</clr-dg-cell>
+        <clr-dg-cell>{{ product.name }}</clr-dg-cell>
+        <clr-dg-cell>{{ product.slug }}</clr-dg-cell>
+        <clr-dg-cell>{{ product.description }}</clr-dg-cell>
+    </clr-dg-row>
+
+    <clr-dg-footer>{{ (products$ | async)?.length }} users</clr-dg-footer>
+</clr-datagrid>

+ 0 - 0
admin-ui/src/app/dashboard/components/dashboard/dashboard.component.scss


+ 25 - 0
admin-ui/src/app/dashboard/components/dashboard/dashboard.component.spec.ts

@@ -0,0 +1,25 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DashboardComponent } from './dashboard.component';
+
+describe('DashboardComponent', () => {
+  let component: DashboardComponent;
+  let fixture: ComponentFixture<DashboardComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ DashboardComponent ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(DashboardComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 21 - 0
admin-ui/src/app/dashboard/components/dashboard/dashboard.component.ts

@@ -0,0 +1,21 @@
+import { Component, OnInit } from '@angular/core';
+import gql from 'graphql-tag';
+import { DataService } from '../../../core/providers/data/data.service';
+import { Observable } from 'rxjs';
+
+@Component({
+    selector: 'vdr-dashboard',
+    templateUrl: './dashboard.component.html',
+    styleUrls: ['./dashboard.component.scss']
+})
+export class DashboardComponent implements OnInit {
+
+    products$: Observable<any[]>;
+
+    constructor(private dataService: DataService) { }
+
+    ngOnInit() {
+        this.products$ = this.dataService.product.getProducts();
+    }
+
+}

+ 15 - 0
admin-ui/src/app/dashboard/dashboard.module.ts

@@ -0,0 +1,15 @@
+import { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { DashboardComponent } from './components/dashboard/dashboard.component';
+import { SharedModule } from '../shared/shared.module';
+import { RouterModule } from '@angular/router';
+import { dashboardRoutes } from './dashboard.routes';
+
+@NgModule({
+    imports: [
+        SharedModule,
+        RouterModule.forChild(dashboardRoutes),
+    ],
+    declarations: [DashboardComponent],
+})
+export class DashboardModule { }

+ 11 - 0
admin-ui/src/app/dashboard/dashboard.routes.ts

@@ -0,0 +1,11 @@
+import { Routes } from '@angular/router';
+
+import { DashboardComponent } from './components/dashboard/dashboard.component';
+
+export const dashboardRoutes: Routes = [
+    {
+        path: '',
+        component: DashboardComponent,
+        pathMatch: 'full',
+    },
+];

+ 32 - 0
admin-ui/src/app/login/components/login/login.component.html

@@ -0,0 +1,32 @@
+<div class="login-wrapper">
+    <form class="login">
+        <label class="title">
+            <h3 class="welcome">Welcome to</h3>
+            Vendure
+        </label>
+        <div class="login-group">
+            <input class="username"
+                   type="text"
+                   name="username"
+                   id="login_username"
+                   [(ngModel)]="username"
+                   placeholder="Username">
+            <input class="password"
+                   name="password"
+                   type="password"
+                   id="login_password"
+                   [(ngModel)]="password"
+                   placeholder="Password">
+            <div class="checkbox">
+                <input type="checkbox" id="rememberme">
+                <label for="rememberme">
+                    Remember me
+                </label>
+            </div>
+            <div class="error active" *ngIf="lastError">
+                {{ lastError }}
+            </div>
+            <button type="submit" class="btn btn-primary" (click)="logIn()">NEXT</button>
+        </div>
+    </form>
+</div>

+ 0 - 0
admin-ui/src/app/login/components/login/login.component.scss


+ 25 - 0
admin-ui/src/app/login/components/login/login.component.spec.ts

@@ -0,0 +1,25 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { LoginComponent } from './login.component';
+
+describe('LoginComponent', () => {
+  let component: LoginComponent;
+  let fixture: ComponentFixture<LoginComponent>;
+
+  beforeEach(async(() => {
+    TestBed.configureTestingModule({
+      declarations: [ LoginComponent ]
+    })
+    .compileComponents();
+  }));
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(LoginComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});

+ 35 - 0
admin-ui/src/app/login/components/login/login.component.ts

@@ -0,0 +1,35 @@
+import { Component, OnInit } from '@angular/core';
+import { UserActions } from '../../../state/user/user-actions';
+import { Router } from '@angular/router';
+
+@Component({
+    selector: 'vdr-login',
+    templateUrl: './login.component.html',
+    styleUrls: ['./login.component.scss'],
+})
+export class LoginComponent {
+
+    username = '';
+    password = '';
+    lastError = '';
+
+    constructor(private userActions: UserActions,
+                private router: Router) { }
+
+    logIn(): void {
+        this.userActions.logIn(this.username, this.password)
+            .subscribe(
+                () => {
+                    console.log('logged in!');
+                    this.router.navigate(['/']);
+                },
+                (err) => {
+                    if (err.status === 401) {
+                        this.lastError = 'Invalid username or password';
+                    } else {
+                        this.lastError = err.error;
+                    }
+                });
+    }
+
+}

+ 18 - 0
admin-ui/src/app/login/login.module.ts

@@ -0,0 +1,18 @@
+import { NgModule } from '@angular/core';
+
+import { LoginComponent } from './components/login/login.component';
+import { SharedModule } from '../shared/shared.module';
+import { RouterModule } from '@angular/router';
+import { loginRoutes } from './login.routes';
+
+@NgModule({
+    imports: [
+        SharedModule,
+        RouterModule.forChild(loginRoutes),
+    ],
+    exports: [],
+    declarations: [LoginComponent],
+    providers: [],
+})
+export class LoginModule {
+}

+ 11 - 0
admin-ui/src/app/login/login.routes.ts

@@ -0,0 +1,11 @@
+import {Routes} from '@angular/router';
+
+import {LoginComponent} from './components/login/login.component';
+
+export const loginRoutes: Routes = [
+    {
+        path: '',
+        component: LoginComponent,
+        pathMatch: 'full',
+    },
+];

+ 13 - 6
admin-ui/src/app/shared/shared.module.ts

@@ -3,14 +3,21 @@ import { NgModule } from '@angular/core';
 import { ClarityModule } from '@clr/angular';
 import { ApolloModule } from 'apollo-angular';
 import { HttpLinkModule } from 'apollo-angular-link-http';
+import { CommonModule } from '@angular/common';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
+
+const IMPORTS = [
+    ClarityModule,
+    CommonModule,
+    FormsModule,
+    ReactiveFormsModule,
+    RouterModule,
+];
 
 @NgModule({
-    imports: [
-        ClarityModule,
-    ],
-    exports: [
-        ClarityModule,
-    ],
+    imports: IMPORTS,
+    exports: IMPORTS,
 })
 export class SharedModule {
 

+ 8 - 0
admin-ui/src/app/state/app-state.ts

@@ -0,0 +1,8 @@
+import { NormalizedCacheObject } from 'apollo-cache-inmemory/src/types';
+
+import { UserState } from './user/user-state';
+
+export interface AppState {
+    entities: NormalizedCacheObject;
+    user: UserState;
+}

+ 24 - 0
admin-ui/src/app/state/handle-error.ts

@@ -0,0 +1,24 @@
+import {EMPTY, Observable} from 'rxjs';
+import {catchError} from 'rxjs/operators';
+
+/**
+ * An error-handling operator which will execute the onErrorSideEffect() function and then
+ * catch the error and complete the stream, so that any further operators are bypassed.
+ *
+ * Designed to be used with the .let() operator or in the pipable style.
+ *
+ * @example
+ * ```
+ * this.dataService.fetchData()
+ *  .let(handleError(err => this.store.dispatch(new Actions.FetchError(err)))
+ *  .do(data => this.store.dispatch(new Actions.FetchSuccess(data))
+ *  .subscribe();
+ * ```
+ */
+export function handleError<T>(onErrorSideEffect: (err: any) => void): (observable: Observable<T>) => Observable<T> {
+    return (observable: Observable<T>) => observable.pipe(
+        catchError(err => {
+            onErrorSideEffect(err);
+            return EMPTY;
+        }));
+}

+ 86 - 0
admin-ui/src/app/state/state-store.service.ts

@@ -0,0 +1,86 @@
+import { Injectable } from '@angular/core';
+import { Observable, Subscription } from 'rxjs';
+import { Action, Store } from '@ngrx/store';
+import { AppState } from './app-state';
+import { distinctUntilChanged, take } from 'rxjs/operators';
+
+console.log('foo');
+
+/**
+ * Wrapper class which wraps the @ngrx/store Store object, and also provides additional
+ * convenience methods for accessing data.
+ */
+@Injectable()
+export class StateStore {
+
+    observeStateSubscriptions: Subscription[] = [];
+
+    constructor(public _internalStore: Store<AppState>) {
+        // expose the AppStore on the window for debug purposes
+        Object.defineProperty(window, 'vnd_app_state', {
+            get: () => this.getState(),
+        });
+        // allow observing of particular state for debug purposes
+        Object.defineProperty(window, 'vnd_observe_state', {
+            get: () => this.observeState.bind(this),
+        });
+    }
+
+    /**
+     * The store object can be manually set (mainly for testing purposes).
+     */
+    public setStore(store): void {
+        this._internalStore = store;
+    }
+
+    public getState(): AppState {
+        // Hacky typing here because TypeScript does not know that .take().subscribe() is a
+        // synchronous operation, and therefore complains that "state" has not been assigned
+        // when it is returned.
+        let state = {} as any;
+        this._internalStore.pipe(take(1)).subscribe(s => state = s);
+        return state;
+    }
+
+    public select<T>(selector: (state: AppState) => T): Observable<T> {
+        return this._internalStore.select<T>(selector).pipe(
+            distinctUntilChanged());
+    }
+
+    public dispatch(action: Action): void {
+        return this._internalStore.dispatch(action);
+    }
+
+    public subscribe(callback: (_) => any): Subscription {
+        return this._internalStore.subscribe(callback);
+    }
+
+    /**
+     * Allows the creation of ad-hoc subscriptions to the app state, and logs the value whenever that part of the state changes.
+     *
+     * Returns an unsubscribe function.
+     */
+    public observeState(selector: (state: AppState) => any): (() => void) | void  {
+        let hasError = false;
+
+        // tslint:disable:no-console
+        const sub = this.select(selector)
+            .subscribe(
+                value => console.log(`%c ${selector.toString()}:`, 'color: #0d35a9;', value),
+                err => {
+                    console.log(`%c "${selector.toString()}" is an invalid selector function:`, 'color: #f00', err.message);
+                    hasError = true;
+                }
+            );
+        // tslint:enable:no-console
+
+        if (!hasError) {
+            this.observeStateSubscriptions.push(sub);
+            return () => {
+                sub.unsubscribe();
+                const index = this.observeStateSubscriptions.indexOf(sub);
+                this.observeStateSubscriptions.splice(index, 1);
+            };
+        }
+    }
+}

+ 8 - 6
admin-ui/src/app/state/state.module.ts

@@ -3,15 +3,14 @@ import { Store, StoreModule } from '@ngrx/store';
 import { apolloReducer, NgrxCache, NgrxCacheModule } from 'apollo-angular-cache-ngrx';
 import { InMemoryCache } from 'apollo-cache-inmemory';
 
+import { StateStore } from './state-store.service';
+import { UserActions } from './user/user-actions';
+import { user } from './user/user-reducer';
+
 export const APOLLO_NGRX_CACHE = new InjectionToken<InMemoryCache>('APOLLO_NGRX_CACHE');
 
 export function createApolloNgrxCache(ngrxCache: NgrxCache, store: Store<any>): InMemoryCache {
-    const cache = ngrxCache.create();
-    (window as any).getState = () => {
-        // tslint:disable-next-line
-        store.select(state => state).subscribe(state => console.log(state));
-    };
-    return cache;
+    return ngrxCache.create();
 }
 
 @NgModule({
@@ -19,6 +18,7 @@ export function createApolloNgrxCache(ngrxCache: NgrxCache, store: Store<any>):
         NgrxCacheModule,
         StoreModule.forRoot({
             entities: apolloReducer,
+            user,
         }),
         NgrxCacheModule.forRoot('entities'),
     ],
@@ -28,6 +28,8 @@ export function createApolloNgrxCache(ngrxCache: NgrxCache, store: Store<any>):
             useFactory: createApolloNgrxCache,
             deps: [NgrxCache, Store],
         },
+        UserActions,
+        StateStore,
     ],
 })
 export class StateModule {}

+ 60 - 0
admin-ui/src/app/state/user/user-actions.ts

@@ -0,0 +1,60 @@
+import {Injectable} from '@angular/core';
+
+import { StateStore } from '../state-store.service';
+
+import {Actions} from './user-state';
+import { DataService } from '../../core/providers/data/data.service';
+import { EMPTY, Observable, of, throwError } from 'rxjs';
+import { catchError, map } from 'rxjs/operators';
+import { LocalStorageService } from '../../core/providers/local-storage/local-storage.service';
+import { handleError } from '../handle-error';
+
+@Injectable()
+export class UserActions {
+
+    constructor(private store: StateStore,
+                private localStorageService: LocalStorageService,
+                private dataService: DataService) {}
+
+    checkAuth(): Observable<boolean> {
+        if (!this.localStorageService.get('authToken')) {
+            return of(false);
+        }
+
+        this.store.dispatch(new Actions.Login());
+
+        return this.dataService.user.checkLoggedIn().pipe(
+            map(result => {
+                this.store.dispatch(new Actions.LoginSuccess({
+                    username: result.identifier,
+                    loginTime: Date.now(),
+                }));
+                return true;
+            }),
+            catchError(err => of(false)),
+        );
+    }
+
+    logIn(username: string, password: string): Observable<any> {
+        this.store.dispatch(new Actions.Login());
+
+        return this.dataService.user.logIn(username, password).pipe(
+            map(result => {
+                this.store.dispatch(new Actions.LoginSuccess({
+                    username,
+                    loginTime: Date.now(),
+                }));
+                this.localStorageService.set('authToken', result.token);
+            }),
+            catchError(err => {
+                this.store.dispatch(new Actions.LoginError(err));
+                return throwError(err);
+            }),
+        );
+    }
+
+    logOut(): void {
+        this.store.dispatch(new Actions.Logout());
+    }
+
+}

+ 68 - 0
admin-ui/src/app/state/user/user-reducer.spec.ts

@@ -0,0 +1,68 @@
+import {user} from './user-reducer';
+import {Actions, UserState} from './user-state';
+
+describe('user reducer', () => {
+
+    it('should handle LOGIN', () => {
+        const state = {
+            error: 'last error message'
+        } as UserState;
+        const action = new Actions.Login();
+        const newState = user(state, action);
+
+        expect(newState).toEqual({
+            loggingIn: true,
+            error: ''
+        } as any);
+    });
+
+    it('should handle LOGIN_SUCCESS', () => {
+        const state = {
+            loggingIn: true,
+            loginTime: -1
+        } as UserState;
+        const action = new Actions.LoginSuccess({
+            username: 'test',
+            loginTime: 12345
+        });
+        const newState = user(state, action);
+
+        expect(newState).toEqual({
+            username: 'test',
+            loggingIn: false,
+            isLoggedIn: true,
+            loginTime: 12345
+        } as any);
+    });
+
+    it('should handle LOGIN_ERROR', () => {
+        const state = {
+            loggingIn: true
+        } as UserState;
+        const action = new Actions.LoginError({ message: 'an error message' });
+        const newState = user(state, action);
+
+        expect(newState).toEqual({
+            loggingIn: false,
+            isLoggedIn: false,
+            error: 'an error message'
+        } as any);
+    });
+
+    it('should handle LOGOUT', () => {
+        const state = {
+            username: 'test',
+            isLoggedIn: true,
+            loginTime: 12345
+        } as UserState;
+        const action = new Actions.Logout();
+        const newState = user(state, action);
+
+        expect(newState).toEqual({
+            username: '',
+            isLoggedIn: false,
+            loginTime: -1
+        } as any);
+    });
+
+});

+ 41 - 0
admin-ui/src/app/state/user/user-reducer.ts

@@ -0,0 +1,41 @@
+import produce from 'immer';
+
+import {reducerCaseNever} from '../../common/utilities/common';
+
+import {Actions, ActionType, initialUserState, UserState} from './user-state';
+
+/**
+ * Reducer for user (auth state etc.)
+ */
+export function user(state: UserState = initialUserState, action: Actions): UserState {
+    return produce(state, draft => {
+        switch (action.type) {
+
+            case ActionType.LOGIN:
+                draft.loggingIn = true;
+                return;
+
+            case ActionType.LOGIN_SUCCESS:
+                draft.username = action.payload.username;
+                draft.loggingIn = false;
+                draft.isLoggedIn = true;
+                draft.loginTime = action.payload.loginTime;
+                return;
+
+            case ActionType.LOGIN_ERROR:
+                draft.loggingIn = true;
+                draft.isLoggedIn = false;
+                return;
+
+            case ActionType.LOGOUT:
+                draft.username = '';
+                draft.loggingIn = false;
+                draft.isLoggedIn = true;
+                draft.loginTime = -1;
+                return;
+
+            default:
+                reducerCaseNever(action);
+        }
+    });
+}

+ 60 - 0
admin-ui/src/app/state/user/user-state.ts

@@ -0,0 +1,60 @@
+import {Action} from '@ngrx/store';
+
+export interface UserState {
+    username: any;
+    loggingIn: boolean;
+    isLoggedIn: boolean;
+    loginTime: number;
+}
+
+export const initialUserState = {
+    username: '',
+    loggingIn: false,
+    isLoggedIn: false,
+    loginTime: -1,
+};
+
+export enum ActionType  {
+    CHECK_LOGGED_IN = 'user/CHECK_LOGGED_IN',
+    LOGIN = 'user/LOGIN',
+    LOGIN_SUCCESS = 'user/LOGIN_SUCCESS',
+    LOGIN_ERROR = 'user/LOGIN_ERROR',
+    LOGOUT = 'user/LOGOUT',
+}
+
+export class CheckLoggedIn implements Action {
+    readonly type = ActionType.LOGIN;
+}
+
+export class Login implements Action {
+    readonly type = ActionType.LOGIN;
+}
+
+export class LoginSuccess implements Action {
+    readonly type = ActionType.LOGIN_SUCCESS;
+    constructor(public payload: { username: string; loginTime: number }) {}
+}
+
+export class LoginError implements Action {
+    readonly type = ActionType.LOGIN_ERROR;
+    constructor(public payload: any) {}
+}
+
+export class Logout implements Action {
+    readonly type = ActionType.LOGOUT;
+}
+
+export const Actions = {
+    CheckLoggedIn,
+    Login,
+    LoginSuccess,
+    LoginError,
+    Logout,
+};
+
+export type Actions =
+    CheckLoggedIn |
+    Login |
+    LoginSuccess |
+    LoginError |
+    Logout;

+ 0 - 7
admin-ui/tsconfig.json

@@ -17,13 +17,6 @@
             "es2017",
             "dom",
             "esnext.asynciterable"
-        ],
-        "plugins": [
-            {
-                "name": "tslint-language-service",
-                "ignoreDefinitionFiles": true,
-                "supressWhileTypeErrorsPresent": true
-            }
         ]
     }
 }

+ 4 - 0
admin-ui/yarn.lock

@@ -3021,6 +3021,10 @@ immediate@~3.0.5:
   version "3.0.6"
   resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
 
+immer@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/immer/-/immer-1.3.1.tgz#3a8c18d9d618c8b7169760a79beec24a1da6a43e"
+
 import-local@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/import-local/-/import-local-1.0.0.tgz#5e4ffdc03f4fe6c009c6729beb29631c2f8227bc"