diff options
author | Gregor Kleen <gkleen@yggdrasil.li> | 2022-06-07 21:38:00 +0200 |
---|---|---|
committer | Gregor Kleen <gkleen@yggdrasil.li> | 2022-06-07 21:38:00 +0200 |
commit | 3bf77d7c3144b16d55f35998dddc0d67bb8c17b2 (patch) | |
tree | 805b343e50d733240b3dae8700d43060943b4dab /overlays/spm/frontend/src/app | |
parent | fc6cf6169868e60c189e4b243330c3717ff159f3 (diff) | |
download | nixos-3bf77d7c3144b16d55f35998dddc0d67bb8c17b2.tar nixos-3bf77d7c3144b16d55f35998dddc0d67bb8c17b2.tar.gz nixos-3bf77d7c3144b16d55f35998dddc0d67bb8c17b2.tar.bz2 nixos-3bf77d7c3144b16d55f35998dddc0d67bb8c17b2.tar.xz nixos-3bf77d7c3144b16d55f35998dddc0d67bb8c17b2.zip |
...
Diffstat (limited to 'overlays/spm/frontend/src/app')
-rw-r--r-- | overlays/spm/frontend/src/app/app-routing.module.ts | 11 | ||||
-rw-r--r-- | overlays/spm/frontend/src/app/app.component.html | 28 | ||||
-rw-r--r-- | overlays/spm/frontend/src/app/app.component.sass | 2 | ||||
-rw-r--r-- | overlays/spm/frontend/src/app/app.component.ts | 89 | ||||
-rw-r--r-- | overlays/spm/frontend/src/app/app.module.ts | 14 | ||||
-rw-r--r-- | overlays/spm/frontend/src/app/color-scheme.service.ts | 62 | ||||
-rw-r--r-- | overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts | 16 | ||||
-rw-r--r-- | overlays/spm/frontend/src/app/spm/spm.component.html | 34 | ||||
-rw-r--r-- | overlays/spm/frontend/src/app/spm/spm.component.sass | 13 | ||||
-rw-r--r-- | overlays/spm/frontend/src/app/spm/spm.component.ts | 181 | ||||
-rw-r--r-- | overlays/spm/frontend/src/app/spm/spm.module.ts | 31 |
11 files changed, 385 insertions, 96 deletions
diff --git a/overlays/spm/frontend/src/app/app-routing.module.ts b/overlays/spm/frontend/src/app/app-routing.module.ts index 2da97cea..bffe8c4d 100644 --- a/overlays/spm/frontend/src/app/app-routing.module.ts +++ b/overlays/spm/frontend/src/app/app-routing.module.ts | |||
@@ -1,17 +1,14 @@ | |||
1 | import { NgModule } from '@angular/core'; | 1 | import { NgModule } from '@angular/core'; |
2 | import { RouterModule, Routes } from '@angular/router'; | 2 | import { RouterModule, Routes, PreloadAllModules } from '@angular/router'; |
3 | |||
4 | import { SpmComponent } from './spm/spm.component'; | ||
5 | import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; | ||
6 | 3 | ||
7 | const routes: Routes = [ | 4 | const routes: Routes = [ |
8 | { path: 'spm', component: SpmComponent }, | 5 | { path: 'spm', loadChildren: () => import('./spm/spm.module').then(m => m.SpmModule) }, |
9 | { path: '', redirectTo: '/spm', pathMatch: 'full' }, | 6 | { path: '', redirectTo: '/spm', pathMatch: 'full' }, |
10 | { path: '**', component: PageNotFoundComponent } | 7 | { path: '**', loadChildren: () => import('./page-not-found/page-not-found.module').then(m => m.PageNotFoundModule) } |
11 | ]; | 8 | ]; |
12 | 9 | ||
13 | @NgModule({ | 10 | @NgModule({ |
14 | imports: [RouterModule.forRoot(routes)], | 11 | imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })], |
15 | exports: [RouterModule] | 12 | exports: [RouterModule] |
16 | }) | 13 | }) |
17 | export class AppRoutingModule { } | 14 | export class AppRoutingModule { } |
diff --git a/overlays/spm/frontend/src/app/app.component.html b/overlays/spm/frontend/src/app/app.component.html index f9b5b8fc..1a2f88b0 100644 --- a/overlays/spm/frontend/src/app/app.component.html +++ b/overlays/spm/frontend/src/app/app.component.html | |||
@@ -1,7 +1,21 @@ | |||
1 | <mat-toolbar color="primary"> | 1 | <div gdAreas="nav | content" gdRows="min-content 1fr" style="min-height: 100vh"> |
2 | <div> | 2 | <mat-toolbar color="primary" gdArea="nav" fxLayout="flex" fxLayoutGap="16px"> |
3 | <a mat-button routerLink="/spm" routerLinkActive="active">spm</a> | 3 | <div> |
4 | </div> | 4 | <span class="mono" [innerHTML]="apiDomain$ | async"></span> |
5 | </mat-toolbar> | 5 | </div> |
6 | <!-- The routed views render in the <router-outlet> --> | 6 | <div> |
7 | <router-outlet></router-outlet> | 7 | <a mat-button routerLink="/spm" routerLinkActive="active">spm</a> |
8 | </div> | ||
9 | </mat-toolbar> | ||
10 | <!-- The routed views render in the <router-outlet> --> | ||
11 | <ng-template #router_loading_indicator> | ||
12 | <div gdArea="content" fxLayout="column" fxLayoutAlign="center stretch"> | ||
13 | <div fxFlex="none" fxFlexAlign="center"> | ||
14 | <mat-spinner></mat-spinner> | ||
15 | </div> | ||
16 | </div> | ||
17 | </ng-template> | ||
18 | <div class="content" gdArea="content" *ngIf="!(routerLoading$ | async); else router_loading_indicator"> | ||
19 | <router-outlet></router-outlet> | ||
20 | </div> | ||
21 | </div> | ||
diff --git a/overlays/spm/frontend/src/app/app.component.sass b/overlays/spm/frontend/src/app/app.component.sass index e69de29b..4732ea49 100644 --- a/overlays/spm/frontend/src/app/app.component.sass +++ b/overlays/spm/frontend/src/app/app.component.sass | |||
@@ -0,0 +1,2 @@ | |||
1 | .content | ||
2 | padding: 16px | ||
diff --git a/overlays/spm/frontend/src/app/app.component.ts b/overlays/spm/frontend/src/app/app.component.ts index 7c231203..11c0f772 100644 --- a/overlays/spm/frontend/src/app/app.component.ts +++ b/overlays/spm/frontend/src/app/app.component.ts | |||
@@ -1,6 +1,18 @@ | |||
1 | import { Component, OnInit, OnDestroy } from '@angular/core'; | 1 | import { Component, OnInit, OnDestroy, Inject, Renderer2, RendererFactory2 } from '@angular/core'; |
2 | import { DOCUMENT } from '@angular/common'; | ||
3 | import { MediaMatcher } from '@angular/cdk/layout'; | ||
4 | import { LocalStorage } from 'ngx-webstorage'; | ||
5 | import { BehaviorSubject, Subscription, Subject } from 'rxjs'; | ||
6 | import { Router, RouterEvent, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router'; | ||
7 | import { HttpClient, HttpHeaders } from '@angular/common/http'; | ||
2 | 8 | ||
3 | import { ColorSchemeService } from './color-scheme.service'; | 9 | import { map, filter } from 'rxjs/operators'; |
10 | |||
11 | type ColorScheme = 'dark' | 'light'; | ||
12 | |||
13 | export interface DomainResponse { | ||
14 | domain: string; | ||
15 | } | ||
4 | 16 | ||
5 | @Component({ | 17 | @Component({ |
6 | selector: 'app-root', | 18 | selector: 'app-root', |
@@ -8,14 +20,81 @@ import { ColorSchemeService } from './color-scheme.service'; | |||
8 | styleUrls: ['./app.component.sass'] | 20 | styleUrls: ['./app.component.sass'] |
9 | }) | 21 | }) |
10 | export class AppComponent implements OnInit, OnDestroy { | 22 | export class AppComponent implements OnInit, OnDestroy { |
23 | private renderer: Renderer2; | ||
24 | colorSchemeMatcher: MediaQueryList; | ||
25 | |||
11 | title = 'spm-frontend'; | 26 | title = 'spm-frontend'; |
12 | 27 | ||
13 | constructor(private colorSchemeService: ColorSchemeService) {} | 28 | public activeColorScheme$: BehaviorSubject<ColorScheme> = new BehaviorSubject<ColorScheme>('dark'); |
29 | |||
30 | @LocalStorage('prefers-color-scheme') | ||
31 | public colorSchemeOverride?: ColorScheme; | ||
32 | |||
33 | colorSchemeSubscription?: Subscription; | ||
34 | |||
35 | router: Router; | ||
36 | public routerLoading$: Subject<boolean> = new Subject<boolean>(); | ||
37 | routerEventSubscription?: Subscription; | ||
38 | |||
39 | http: HttpClient; | ||
40 | apiDomain$: Subject<string> = new Subject<string>(); | ||
41 | |||
42 | constructor( rendererFactory: RendererFactory2, | ||
43 | mediaMatcher: MediaMatcher, | ||
44 | @Inject(DOCUMENT) private document: Document, | ||
45 | router: Router, | ||
46 | http: HttpClient, | ||
47 | ) { | ||
48 | this.renderer = rendererFactory.createRenderer(null, null); | ||
49 | this.colorSchemeMatcher = mediaMatcher.matchMedia('(prefers-color-scheme: dark)'); | ||
50 | this.document = document; | ||
51 | |||
52 | this.colorSchemeListener = this.colorSchemeListener.bind(this); | ||
53 | this.colorSchemeMatcher.addEventListener('change', this.colorSchemeListener); | ||
54 | this.activeColorScheme$.next(this.getColorScheme()); | ||
55 | |||
56 | this.router = router; | ||
57 | this.http = http; | ||
58 | } | ||
59 | |||
60 | private colorSchemeListener(event: { matches: boolean; }) { | ||
61 | this.activeColorScheme$.next(this.getColorScheme(event)); | ||
62 | } | ||
63 | |||
64 | private getColorScheme(event?: { matches: boolean; }): ColorScheme { | ||
65 | if (this.colorSchemeOverride) { | ||
66 | return this.colorSchemeOverride; | ||
67 | } else if (event) { | ||
68 | return event.matches ? 'dark' : 'light'; | ||
69 | } else { | ||
70 | return this.colorSchemeMatcher.matches ? 'dark' : 'light'; | ||
71 | } | ||
72 | } | ||
73 | |||
74 | updateColorScheme(scheme: ColorScheme) { | ||
75 | this.colorSchemeOverride = scheme; | ||
76 | this.activeColorScheme$.next(scheme); | ||
77 | } | ||
14 | 78 | ||
15 | ngOnInit() { | 79 | ngOnInit() { |
16 | this.colorSchemeService.init(); | 80 | this.colorSchemeSubscription = this.activeColorScheme$.subscribe(scheme => { this.renderer.setAttribute(this.document.body, 'data-color-scheme', scheme); }); |
81 | |||
82 | this.routerEventSubscription = this.router.events.pipe( | ||
83 | filter(event => | ||
84 | event instanceof NavigationStart || | ||
85 | event instanceof NavigationEnd || | ||
86 | event instanceof NavigationCancel || | ||
87 | event instanceof NavigationError | ||
88 | ), | ||
89 | map(event => event instanceof NavigationStart) | ||
90 | ).subscribe(this.routerLoading$); | ||
91 | |||
92 | this.http.get<DomainResponse>('/domain', { headers: new HttpHeaders({ 'Accept': 'application/json' }) }).pipe(map((resp: DomainResponse) => resp.domain)).subscribe(this.apiDomain$); | ||
17 | } | 93 | } |
18 | ngOnDestroy() { | 94 | ngOnDestroy() { |
19 | this.colorSchemeService.destroy(); | 95 | this.colorSchemeMatcher.removeEventListener('change', this.colorSchemeListener); |
96 | |||
97 | this.colorSchemeSubscription?.unsubscribe(); | ||
98 | this.routerEventSubscription?.unsubscribe(); | ||
20 | } | 99 | } |
21 | } | 100 | } |
diff --git a/overlays/spm/frontend/src/app/app.module.ts b/overlays/spm/frontend/src/app/app.module.ts index 914f73a2..98e5106e 100644 --- a/overlays/spm/frontend/src/app/app.module.ts +++ b/overlays/spm/frontend/src/app/app.module.ts | |||
@@ -1,30 +1,30 @@ | |||
1 | import { NgModule } from '@angular/core'; | 1 | import { NgModule } from '@angular/core'; |
2 | import { BrowserModule } from '@angular/platform-browser'; | 2 | import { BrowserModule } from '@angular/platform-browser'; |
3 | import { NgxWebstorageModule } from 'ngx-webstorage'; | ||
3 | 4 | ||
4 | import { AppRoutingModule } from './app-routing.module'; | 5 | import { AppRoutingModule } from './app-routing.module'; |
5 | import { AppComponent } from './app.component'; | 6 | import { AppComponent } from './app.component'; |
6 | import { SpmComponent } from './spm/spm.component'; | ||
7 | import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; | ||
8 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; | 7 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; |
9 | import { MatIconModule } from '@angular/material/icon'; | ||
10 | import { HttpClientModule } from "@angular/common/http"; | 8 | import { HttpClientModule } from "@angular/common/http"; |
11 | import { MatToolbarModule } from '@angular/material/toolbar'; | 9 | import { MatToolbarModule } from '@angular/material/toolbar'; |
12 | import { MatButtonModule } from '@angular/material/button'; | 10 | import { MatButtonModule } from '@angular/material/button'; |
11 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; | ||
12 | import { FlexLayoutModule } from "@angular/flex-layout"; | ||
13 | 13 | ||
14 | @NgModule({ | 14 | @NgModule({ |
15 | declarations: [ | 15 | declarations: [ |
16 | AppComponent, | 16 | AppComponent, |
17 | SpmComponent, | ||
18 | PageNotFoundComponent | ||
19 | ], | 17 | ], |
20 | imports: [ | 18 | imports: [ |
21 | BrowserModule, | 19 | BrowserModule, |
20 | NgxWebstorageModule.forRoot(), | ||
22 | AppRoutingModule, | 21 | AppRoutingModule, |
23 | BrowserAnimationsModule, | 22 | BrowserAnimationsModule, |
24 | MatIconModule, | ||
25 | HttpClientModule, | 23 | HttpClientModule, |
26 | MatToolbarModule, | 24 | MatToolbarModule, |
27 | MatButtonModule | 25 | MatButtonModule, |
26 | MatProgressSpinnerModule, | ||
27 | FlexLayoutModule, | ||
28 | ], | 28 | ], |
29 | providers: [], | 29 | providers: [], |
30 | bootstrap: [AppComponent] | 30 | bootstrap: [AppComponent] |
diff --git a/overlays/spm/frontend/src/app/color-scheme.service.ts b/overlays/spm/frontend/src/app/color-scheme.service.ts deleted file mode 100644 index c56e9c23..00000000 --- a/overlays/spm/frontend/src/app/color-scheme.service.ts +++ /dev/null | |||
@@ -1,62 +0,0 @@ | |||
1 | import { Injectable, Renderer2, RendererFactory2 } from '@angular/core'; | ||
2 | import { MediaMatcher } from '@angular/cdk/layout'; | ||
3 | import { LocalStorage } from 'ngx-webstorage'; | ||
4 | |||
5 | type ColorScheme = "dark" | "light"; | ||
6 | |||
7 | @Injectable({ | ||
8 | providedIn: 'root' | ||
9 | }) | ||
10 | export class ColorSchemeService { | ||
11 | private renderer: Renderer2; | ||
12 | matcher!: MediaQueryList; | ||
13 | |||
14 | @LocalStorage('prefers-color-scheme') | ||
15 | public colorSchemeOverride: ColorScheme; | ||
16 | |||
17 | public activeColorScheme: BehaviorSubject<ColorScheme> = new BehaviorSubject("light"); | ||
18 | |||
19 | constructor(rendererFactory: RendererFactory2, | ||
20 | mediaMatcher: MediaMatcher | ||
21 | ) { | ||
22 | // Create new renderer from renderFactory, to make it possible to use renderer2 in a service | ||
23 | this.renderer = rendererFactory.createRenderer(null, null); | ||
24 | this.matcher = mediaMatcher.matchMedia('(prefers-color-scheme: dark)'); | ||
25 | |||
26 | this._listener = this._listener.bind(this); | ||
27 | } | ||
28 | |||
29 | init() { | ||
30 | this.matcher.addEventListener('change', this._listener); | ||
31 | this.load(); | ||
32 | } | ||
33 | |||
34 | _listener(event: { matches: any; }) { | ||
35 | this.activeColorScheme.next(event.matches ? 'dark' : 'light'); | ||
36 | } | ||
37 | |||
38 | destroy() { | ||
39 | this.matcher.removeEventListener('change', this._listener); | ||
40 | } | ||
41 | |||
42 | _getColorScheme() { | ||
43 | if (this.colorSchemeOverride) { | ||
44 | this.colorSchemeOverride | ||
45 | } else { | ||
46 | this.matcher.matches ? 'dark' : 'light' | ||
47 | } | ||
48 | } | ||
49 | |||
50 | load() { | ||
51 | this.activeColorScheme.next(this._getColorScheme()); | ||
52 | } | ||
53 | |||
54 | update(scheme: ColorScheme) { | ||
55 | this.colorSchemeOverride = scheme; | ||
56 | this.activeColorScheme.next(scheme); | ||
57 | } | ||
58 | |||
59 | currentActive() { | ||
60 | return this.colorScheme; | ||
61 | } | ||
62 | } | ||
diff --git a/overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts b/overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts new file mode 100644 index 00000000..b9d8945c --- /dev/null +++ b/overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts | |||
@@ -0,0 +1,16 @@ | |||
1 | import { NgModule } from '@angular/core'; | ||
2 | import { PageNotFoundComponent } from './page-not-found.component'; | ||
3 | import { RouterModule } from '@angular/router'; | ||
4 | |||
5 | @NgModule({ | ||
6 | declarations: [PageNotFoundComponent], | ||
7 | exports: [PageNotFoundComponent], | ||
8 | imports: [ | ||
9 | RouterModule.forChild([{ | ||
10 | path: '', | ||
11 | pathMatch: 'full', | ||
12 | component: PageNotFoundComponent | ||
13 | }]), | ||
14 | ] | ||
15 | }) | ||
16 | export class PageNotFoundModule {} | ||
diff --git a/overlays/spm/frontend/src/app/spm/spm.component.html b/overlays/spm/frontend/src/app/spm/spm.component.html index d1339d95..5d0e625a 100644 --- a/overlays/spm/frontend/src/app/spm/spm.component.html +++ b/overlays/spm/frontend/src/app/spm/spm.component.html | |||
@@ -1,3 +1,33 @@ | |||
1 | <p>spm works!</p> | 1 | <div id="mail-panel-container" fxLayout="row wrap" style="gap: 16px"> |
2 | <ng-template ngFor [ngForOf]="spmMails$ | async | keyvalue: asIsOrder" let-entry> | ||
3 | <mat-card> | ||
4 | <mat-card-title class="mono" *ngIf="entry.value.state !== 'loading'">{{entry.value.local}}</mat-card-title> | ||
5 | <mat-card-subtitle class="mono" *ngIf="entry.value.state !== 'loading'">@{{entry.value.domain}}</mat-card-subtitle> | ||
6 | <mat-card-content *ngIf="entry.value.state === 'loading'"> | ||
7 | <mat-spinner style="margin: auto"></mat-spinner> | ||
8 | </mat-card-content> | ||
9 | <mat-card-actions> | ||
10 | <button mat-raised-button color="primary" (click)="claim(entry.key)" [disabled]="entry.value.state === 'loaded' || entry.value.state === 'claiming' ? null : true"> | ||
11 | <mat-icon aria-hidden="false" aria-label="Claim" svgIcon="check"></mat-icon> | ||
12 | <span *ngIf="entry.value.state !== 'claimed'">Claim</span> | ||
13 | <span *ngIf="entry.value.state === 'claimed'">Claimed</span> | ||
14 | </button> | ||
15 | <button mat-stroked-button color="warn" (click)="forget(entry.key)"> | ||
16 | <mat-icon aria-hidden="false" aria-label="Forget" svgIcon="delete_forever"></mat-icon> | ||
17 | <span *ngIf="entry.value.state !== 'claimed'">Forget</span> | ||
18 | <span *ngIf="entry.value.state === 'claimed'">Hide</span> | ||
19 | </button> | ||
20 | </mat-card-actions> | ||
21 | <mat-card-footer *ngIf="entry.value.state === 'loaded' && entry.value.percent_expiration"> | ||
22 | <mat-progress-bar mode="determinate" [value]="entry.value.percent_expiration"></mat-progress-bar> | ||
23 | </mat-card-footer> | ||
24 | <mat-card-footer *ngIf="entry.value.state === 'claiming'"> | ||
25 | <mat-progress-bar mode="indeterminate"></mat-progress-bar> | ||
26 | </mat-card-footer> | ||
27 | </mat-card> | ||
28 | </ng-template> | ||
29 | </div> | ||
2 | 30 | ||
3 | <mat-icon aria-hidden="false" aria-label="Thumbs up" svgIcon="thumb_up"></mat-icon> | 31 | <button id="add-button" mat-fab color="accent" (click)="add()"> |
32 | <mat-icon aria-hidden="false" aria-label="Add" svgIcon="add"></mat-icon> | ||
33 | </button> | ||
diff --git a/overlays/spm/frontend/src/app/spm/spm.component.sass b/overlays/spm/frontend/src/app/spm/spm.component.sass index e69de29b..bda4dd4a 100644 --- a/overlays/spm/frontend/src/app/spm/spm.component.sass +++ b/overlays/spm/frontend/src/app/spm/spm.component.sass | |||
@@ -0,0 +1,13 @@ | |||
1 | #add-button | ||
2 | position: fixed | ||
3 | bottom: 16px | ||
4 | right: 16px | ||
5 | |||
6 | #mail-panel-container | ||
7 | margin-bottom: 88px | ||
8 | |||
9 | .mat-card | ||
10 | overflow: hidden | ||
11 | |||
12 | .mat-card-footer | ||
13 | margin: -16px | ||
diff --git a/overlays/spm/frontend/src/app/spm/spm.component.ts b/overlays/spm/frontend/src/app/spm/spm.component.ts index 7d172052..f0655b55 100644 --- a/overlays/spm/frontend/src/app/spm/spm.component.ts +++ b/overlays/spm/frontend/src/app/spm/spm.component.ts | |||
@@ -1,24 +1,193 @@ | |||
1 | import { Component, OnInit } from '@angular/core'; | 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; |
2 | import { MatIconRegistry, MatIconModule } from '@angular/material/icon'; | 2 | import { MatIconRegistry, MatIconModule } from '@angular/material/icon'; |
3 | import { DomSanitizer } from "@angular/platform-browser"; | 3 | import { DomSanitizer } from "@angular/platform-browser"; |
4 | import { BehaviorSubject, interval, Subscription, catchError, throwError } from 'rxjs'; | ||
5 | import { HttpClient, HttpHeaders, HttpErrorResponse, HttpResponse } from '@angular/common/http'; | ||
6 | |||
7 | import { v4 as uuidv4 } from 'uuid'; | ||
8 | import jwtDecode, { JwtPayload } from "jwt-decode"; | ||
9 | |||
10 | import { map, withLatestFrom } from 'rxjs/operators'; | ||
11 | |||
12 | export interface LoadingSpmMail { | ||
13 | state: "loading"; | ||
14 | } | ||
15 | |||
16 | export interface LoadedSpmMail { | ||
17 | state: "loaded" | "claiming"; | ||
18 | local: string; | ||
19 | domain: string; | ||
20 | jwt: string; | ||
21 | start?: Date; | ||
22 | expiration?: Date; | ||
23 | percent_expiration?: number; | ||
24 | } | ||
25 | |||
26 | export interface ExpiredSpmMail { | ||
27 | state: "expired"; | ||
28 | local: string; | ||
29 | domain: string; | ||
30 | } | ||
31 | |||
32 | export interface ClaimedSpmMail { | ||
33 | state: "claimed"; | ||
34 | local: string; | ||
35 | domain: string; | ||
36 | } | ||
37 | |||
38 | export interface SpmJwtUnregisteredPayload { | ||
39 | local: string; | ||
40 | } | ||
41 | |||
42 | type SpmJwtPayload = JwtPayload & SpmJwtUnregisteredPayload; | ||
43 | |||
44 | export type SpmMail = LoadingSpmMail | LoadedSpmMail | ClaimedSpmMail | ExpiredSpmMail; | ||
45 | |||
46 | function percentExpiration(start: Date, exp: Date, now?: Date) { | ||
47 | if (!now) { | ||
48 | now = new Date(); | ||
49 | } | ||
50 | |||
51 | if (now < start) { | ||
52 | return 100; | ||
53 | } else if (exp < now) { | ||
54 | return 0; | ||
55 | } else { | ||
56 | const length = exp.getTime() - start.getTime(); | ||
57 | const diff = exp.getTime() - now.getTime(); | ||
58 | return 100 * (diff / length); | ||
59 | } | ||
60 | } | ||
61 | |||
62 | function updateSpmExpiration(value: SpmMail): SpmMail { | ||
63 | if (value.state === 'loaded') { | ||
64 | const percent_expiration = value.expiration && value.start ? percentExpiration(value.start, value.expiration) : undefined; | ||
65 | if (percent_expiration !== undefined && percent_expiration <= 0) { | ||
66 | return {state: "expired", local: value.local, domain: value.domain}; | ||
67 | } else { | ||
68 | return {...value, percent_expiration: percent_expiration} | ||
69 | } | ||
70 | } else { | ||
71 | return value; | ||
72 | } | ||
73 | } | ||
4 | 74 | ||
5 | @Component({ | 75 | @Component({ |
6 | selector: 'app-spm', | 76 | selector: 'app-spm', |
7 | templateUrl: './spm.component.html', | 77 | templateUrl: './spm.component.html', |
8 | styleUrls: ['./spm.component.sass'] | 78 | styleUrls: ['./spm.component.sass'] |
9 | }) | 79 | }) |
10 | export class SpmComponent implements OnInit { | 80 | export class SpmComponent implements OnInit, OnDestroy { |
81 | spmMails$: BehaviorSubject<Map<string, SpmMail>> = new BehaviorSubject(new Map()); | ||
82 | http: HttpClient; | ||
83 | |||
84 | intervalSubscription?: Subscription; | ||
11 | 85 | ||
12 | constructor(private matIconRegistry: MatIconRegistry, | 86 | constructor(private matIconRegistry: MatIconRegistry, |
13 | private domSanitizer: DomSanitizer | 87 | private domSanitizer: DomSanitizer, |
88 | http: HttpClient, | ||
14 | ) { | 89 | ) { |
15 | this.matIconRegistry.addSvgIcon( | 90 | this.matIconRegistry.addSvgIcon( |
16 | `thumb_up`, | 91 | `add`, |
17 | this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/thumb_up/baseline.svg`) | 92 | this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/add/baseline.svg`) |
93 | ); | ||
94 | this.matIconRegistry.addSvgIcon( | ||
95 | `check`, | ||
96 | this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/check/baseline.svg`) | ||
97 | ); | ||
98 | this.matIconRegistry.addSvgIcon( | ||
99 | `delete_forever`, | ||
100 | this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/delete_forever/baseline.svg`) | ||
18 | ); | 101 | ); |
102 | |||
103 | this.http = http; | ||
104 | } | ||
105 | |||
106 | add() { | ||
107 | const ident = uuidv4(); | ||
108 | const curr: Map<string, SpmMail> = this.spmMails$.getValue(); | ||
109 | curr.set(ident, { state: 'loading' }); | ||
110 | this.spmMails$.next(curr); | ||
111 | this.http.get('/spm/generate', { headers: new HttpHeaders({ 'Accept': 'text/plain' }), observe: 'body' as const, responseType: 'text' as const }).subscribe(payload => this.load(ident, payload)) | ||
112 | } | ||
113 | |||
114 | load(k: string, encoded: string) { | ||
115 | const payload = jwtDecode<SpmJwtPayload>(encoded); | ||
116 | if (typeof payload.aud === 'string') { | ||
117 | const curr: Map<string, SpmMail> = this.spmMails$.getValue(); | ||
118 | curr.set(k, { | ||
119 | state: 'loaded', | ||
120 | local: payload.local, | ||
121 | domain: payload.aud, | ||
122 | jwt: encoded, | ||
123 | expiration: payload.exp ? new Date(1000 * payload.exp) : undefined, | ||
124 | start: payload.nbf ? new Date(1000 * payload.nbf) : undefined, | ||
125 | percent_expiration: payload.exp && payload.nbf ? percentExpiration(new Date(1000 * payload.nbf), new Date(1000 * payload.exp)) : undefined | ||
126 | }); | ||
127 | this.spmMails$.next(curr); | ||
128 | } | ||
129 | } | ||
130 | |||
131 | handleClaimError(k: string, error: HttpErrorResponse) { | ||
132 | this.setState(k, 'loaded'); | ||
133 | |||
134 | return throwError(() => new Error('Claiming failed.')); | ||
135 | } | ||
136 | |||
137 | claim(k: string) { | ||
138 | const val = this.spmMails$.getValue().get(k); | ||
139 | if (!val || val.state !== 'loaded') | ||
140 | return; | ||
141 | |||
142 | this.setState(k, 'claiming'); | ||
143 | |||
144 | this.http.post('/spm/claim', val.jwt, { observe: 'response' as const, headers: new HttpHeaders({ 'Content-Type': 'text/plain; charset=utf-8' }) }).pipe(catchError(error => this.handleClaimError(k, error))).subscribe(response => this.claimed(k, response)) | ||
19 | } | 145 | } |
20 | 146 | ||
21 | ngOnInit(): void { | 147 | setState(k: string, state: 'loaded' | 'claiming') { |
148 | const curr: Map<string, SpmMail> = this.spmMails$.getValue(); | ||
149 | const val = curr.get(k); | ||
150 | if (!val || (val.state !== 'loaded' && val.state !== 'claiming')) | ||
151 | return; | ||
152 | |||
153 | curr.set(k, {...val, state: state}); | ||
154 | this.spmMails$.next(curr); | ||
155 | } | ||
156 | |||
157 | claimed(k: string, response: HttpResponse<any>) { | ||
158 | if (response.status !== 204) { | ||
159 | this.setState(k, 'loaded'); | ||
160 | return; | ||
161 | } | ||
162 | |||
163 | const curr: Map<string, SpmMail> = this.spmMails$.getValue(); | ||
164 | const val = curr.get(k); | ||
165 | if (val && (val.state === 'claiming' || val.state === 'loaded')) { | ||
166 | curr.set(k, {state: "claimed", local: val.local, domain: val.domain}); | ||
167 | } | ||
168 | this.spmMails$.next(curr); | ||
22 | } | 169 | } |
23 | 170 | ||
171 | forget(k: string) { | ||
172 | const curr: Map<string, SpmMail> = this.spmMails$.getValue(); | ||
173 | curr.delete(k); | ||
174 | this.spmMails$.next(curr); | ||
175 | } | ||
176 | |||
177 | stepSpmMails(curr: Map<string, SpmMail>) { | ||
178 | curr.forEach((value, key) => curr.set(key, updateSpmExpiration(value))); | ||
179 | this.spmMails$.next(curr); | ||
180 | } | ||
181 | |||
182 | ngOnInit() { | ||
183 | this.intervalSubscription = interval(1000).pipe(withLatestFrom(this.spmMails$)).subscribe(([_, mails]: [number, Map<string, SpmMail>]) => this.stepSpmMails(mails)); | ||
184 | } | ||
185 | |||
186 | ngOnDestroy() { | ||
187 | this.intervalSubscription?.unsubscribe(); | ||
188 | } | ||
189 | |||
190 | asIsOrder(a: any, b: any) { | ||
191 | return -1; | ||
192 | } | ||
24 | } | 193 | } |
diff --git a/overlays/spm/frontend/src/app/spm/spm.module.ts b/overlays/spm/frontend/src/app/spm/spm.module.ts new file mode 100644 index 00000000..f51e6e98 --- /dev/null +++ b/overlays/spm/frontend/src/app/spm/spm.module.ts | |||
@@ -0,0 +1,31 @@ | |||
1 | import { NgModule } from '@angular/core'; | ||
2 | import { CommonModule } from '@angular/common'; | ||
3 | import { SpmComponent } from './spm.component'; | ||
4 | import { RouterModule } from '@angular/router'; | ||
5 | |||
6 | import { MatIconModule } from '@angular/material/icon'; | ||
7 | import { MatButtonModule } from '@angular/material/button'; | ||
8 | import { MatCardModule } from '@angular/material/card'; | ||
9 | import { FlexLayoutModule } from "@angular/flex-layout"; | ||
10 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; | ||
11 | import { MatProgressBarModule } from '@angular/material/progress-bar'; | ||
12 | |||
13 | @NgModule({ | ||
14 | declarations: [SpmComponent], | ||
15 | exports: [SpmComponent], | ||
16 | imports: [ | ||
17 | RouterModule.forChild([{ | ||
18 | path: '', | ||
19 | pathMatch: 'full', | ||
20 | component: SpmComponent | ||
21 | }]), | ||
22 | MatIconModule, | ||
23 | MatButtonModule, | ||
24 | MatCardModule, | ||
25 | CommonModule, | ||
26 | FlexLayoutModule, | ||
27 | MatProgressSpinnerModule, | ||
28 | MatProgressBarModule, | ||
29 | ] | ||
30 | }) | ||
31 | export class SpmModule {} | ||