summaryrefslogtreecommitdiff
path: root/overlays/spm/frontend/src
diff options
context:
space:
mode:
authorGregor Kleen <gkleen@yggdrasil.li>2022-06-07 21:38:00 +0200
committerGregor Kleen <gkleen@yggdrasil.li>2022-06-07 21:38:00 +0200
commit3bf77d7c3144b16d55f35998dddc0d67bb8c17b2 (patch)
tree805b343e50d733240b3dae8700d43060943b4dab /overlays/spm/frontend/src
parentfc6cf6169868e60c189e4b243330c3717ff159f3 (diff)
downloadnixos-3bf77d7c3144b16d55f35998dddc0d67bb8c17b2.tar
nixos-3bf77d7c3144b16d55f35998dddc0d67bb8c17b2.tar.gz
nixos-3bf77d7c3144b16d55f35998dddc0d67bb8c17b2.tar.bz2
nixos-3bf77d7c3144b16d55f35998dddc0d67bb8c17b2.tar.xz
nixos-3bf77d7c3144b16d55f35998dddc0d67bb8c17b2.zip
...
Diffstat (limited to 'overlays/spm/frontend/src')
-rw-r--r--overlays/spm/frontend/src/app/app-routing.module.ts11
-rw-r--r--overlays/spm/frontend/src/app/app.component.html28
-rw-r--r--overlays/spm/frontend/src/app/app.component.sass2
-rw-r--r--overlays/spm/frontend/src/app/app.component.ts89
-rw-r--r--overlays/spm/frontend/src/app/app.module.ts14
-rw-r--r--overlays/spm/frontend/src/app/color-scheme.service.ts62
-rw-r--r--overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts16
-rw-r--r--overlays/spm/frontend/src/app/spm/spm.component.html34
-rw-r--r--overlays/spm/frontend/src/app/spm/spm.component.sass13
-rw-r--r--overlays/spm/frontend/src/app/spm/spm.component.ts181
-rw-r--r--overlays/spm/frontend/src/app/spm/spm.module.ts31
-rw-r--r--overlays/spm/frontend/src/custom-theme.scss2
-rw-r--r--overlays/spm/frontend/src/styles.sass1
13 files changed, 388 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 @@
1import { NgModule } from '@angular/core'; 1import { NgModule } from '@angular/core';
2import { RouterModule, Routes } from '@angular/router'; 2import { RouterModule, Routes, PreloadAllModules } from '@angular/router';
3
4import { SpmComponent } from './spm/spm.component';
5import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
6 3
7const routes: Routes = [ 4const 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})
17export class AppRoutingModule { } 14export 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 @@
1import { Component, OnInit, OnDestroy } from '@angular/core'; 1import { Component, OnInit, OnDestroy, Inject, Renderer2, RendererFactory2 } from '@angular/core';
2import { DOCUMENT } from '@angular/common';
3import { MediaMatcher } from '@angular/cdk/layout';
4import { LocalStorage } from 'ngx-webstorage';
5import { BehaviorSubject, Subscription, Subject } from 'rxjs';
6import { Router, RouterEvent, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router';
7import { HttpClient, HttpHeaders } from '@angular/common/http';
2 8
3import { ColorSchemeService } from './color-scheme.service'; 9import { map, filter } from 'rxjs/operators';
10
11type ColorScheme = 'dark' | 'light';
12
13export 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})
10export class AppComponent implements OnInit, OnDestroy { 22export 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 @@
1import { NgModule } from '@angular/core'; 1import { NgModule } from '@angular/core';
2import { BrowserModule } from '@angular/platform-browser'; 2import { BrowserModule } from '@angular/platform-browser';
3import { NgxWebstorageModule } from 'ngx-webstorage';
3 4
4import { AppRoutingModule } from './app-routing.module'; 5import { AppRoutingModule } from './app-routing.module';
5import { AppComponent } from './app.component'; 6import { AppComponent } from './app.component';
6import { SpmComponent } from './spm/spm.component';
7import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
8import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 7import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
9import { MatIconModule } from '@angular/material/icon';
10import { HttpClientModule } from "@angular/common/http"; 8import { HttpClientModule } from "@angular/common/http";
11import { MatToolbarModule } from '@angular/material/toolbar'; 9import { MatToolbarModule } from '@angular/material/toolbar';
12import { MatButtonModule } from '@angular/material/button'; 10import { MatButtonModule } from '@angular/material/button';
11import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
12import { 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 @@
1import { Injectable, Renderer2, RendererFactory2 } from '@angular/core';
2import { MediaMatcher } from '@angular/cdk/layout';
3import { LocalStorage } from 'ngx-webstorage';
4
5type ColorScheme = "dark" | "light";
6
7@Injectable({
8 providedIn: 'root'
9})
10export 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 @@
1import { NgModule } from '@angular/core';
2import { PageNotFoundComponent } from './page-not-found.component';
3import { 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})
16export 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 @@
1import { Component, OnInit } from '@angular/core'; 1import { Component, OnInit, OnDestroy } from '@angular/core';
2import { MatIconRegistry, MatIconModule } from '@angular/material/icon'; 2import { MatIconRegistry, MatIconModule } from '@angular/material/icon';
3import { DomSanitizer } from "@angular/platform-browser"; 3import { DomSanitizer } from "@angular/platform-browser";
4import { BehaviorSubject, interval, Subscription, catchError, throwError } from 'rxjs';
5import { HttpClient, HttpHeaders, HttpErrorResponse, HttpResponse } from '@angular/common/http';
6
7import { v4 as uuidv4 } from 'uuid';
8import jwtDecode, { JwtPayload } from "jwt-decode";
9
10import { map, withLatestFrom } from 'rxjs/operators';
11
12export interface LoadingSpmMail {
13 state: "loading";
14}
15
16export 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
26export interface ExpiredSpmMail {
27 state: "expired";
28 local: string;
29 domain: string;
30}
31
32export interface ClaimedSpmMail {
33 state: "claimed";
34 local: string;
35 domain: string;
36}
37
38export interface SpmJwtUnregisteredPayload {
39 local: string;
40}
41
42type SpmJwtPayload = JwtPayload & SpmJwtUnregisteredPayload;
43
44export type SpmMail = LoadingSpmMail | LoadedSpmMail | ClaimedSpmMail | ExpiredSpmMail;
45
46function 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
62function 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})
10export class SpmComponent implements OnInit { 80export 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 @@
1import { NgModule } from '@angular/core';
2import { CommonModule } from '@angular/common';
3import { SpmComponent } from './spm.component';
4import { RouterModule } from '@angular/router';
5
6import { MatIconModule } from '@angular/material/icon';
7import { MatButtonModule } from '@angular/material/button';
8import { MatCardModule } from '@angular/material/card';
9import { FlexLayoutModule } from "@angular/flex-layout";
10import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
11import { 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})
31export class SpmModule {}
diff --git a/overlays/spm/frontend/src/custom-theme.scss b/overlays/spm/frontend/src/custom-theme.scss
index 5c2d48aa..36369bd5 100644
--- a/overlays/spm/frontend/src/custom-theme.scss
+++ b/overlays/spm/frontend/src/custom-theme.scss
@@ -49,3 +49,5 @@ $spm-frontend-dark-theme: mat.define-dark-theme((
49 49
50html, body { height: 100%; } 50html, body { height: 100%; }
51body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 51body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
52
53.mono { font-family: "Source Sans Pro", monospace; }
diff --git a/overlays/spm/frontend/src/styles.sass b/overlays/spm/frontend/src/styles.sass
index 4f2cb82a..4ffe0b36 100644
--- a/overlays/spm/frontend/src/styles.sass
+++ b/overlays/spm/frontend/src/styles.sass
@@ -1,2 +1,3 @@
1/* You can add global styles to this file, and also import other style files */ 1/* You can add global styles to this file, and also import other style files */
2@import @fontsource/roboto 2@import @fontsource/roboto
3@import @fontsource/source-sans-pro