From 3bf77d7c3144b16d55f35998dddc0d67bb8c17b2 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Tue, 7 Jun 2022 21:38:00 +0200 Subject: ... --- overlays/spm/frontend/angular.json | 2 +- overlays/spm/frontend/package.json | 5 + .../spm/frontend/src/app/app-routing.module.ts | 11 +- overlays/spm/frontend/src/app/app.component.html | 28 +++- overlays/spm/frontend/src/app/app.component.sass | 2 + overlays/spm/frontend/src/app/app.component.ts | 89 +++++++++- overlays/spm/frontend/src/app/app.module.ts | 14 +- .../spm/frontend/src/app/color-scheme.service.ts | 62 ------- .../app/page-not-found/page-not-found.module.ts | 16 ++ .../spm/frontend/src/app/spm/spm.component.html | 34 +++- .../spm/frontend/src/app/spm/spm.component.sass | 13 ++ overlays/spm/frontend/src/app/spm/spm.component.ts | 181 ++++++++++++++++++++- overlays/spm/frontend/src/app/spm/spm.module.ts | 31 ++++ overlays/spm/frontend/src/custom-theme.scss | 2 + overlays/spm/frontend/src/styles.sass | 1 + overlays/spm/frontend/yarn-project.nix | 8 +- overlays/spm/frontend/yarn.lock | 41 +++++ overlays/spm/lib/Spm/Api.hs | 19 ++- overlays/spm/server/Spm/Server.hs | 16 +- 19 files changed, 470 insertions(+), 105 deletions(-) delete mode 100644 overlays/spm/frontend/src/app/color-scheme.service.ts create mode 100644 overlays/spm/frontend/src/app/page-not-found/page-not-found.module.ts create mode 100644 overlays/spm/frontend/src/app/spm/spm.module.ts (limited to 'overlays/spm') diff --git a/overlays/spm/frontend/angular.json b/overlays/spm/frontend/angular.json index cc08d665..a25bfd0b 100644 --- a/overlays/spm/frontend/angular.json +++ b/overlays/spm/frontend/angular.json @@ -54,7 +54,7 @@ "assets": [ "src/assets", { - "glob": "@(thumb_up)/baseline.svg", + "glob": "@(add|check|delete_forever)/baseline.svg", "input": "./node_modules/@material-icons/svg/svg", "output": "/icons/" } diff --git a/overlays/spm/frontend/package.json b/overlays/spm/frontend/package.json index 74010466..54a301a0 100644 --- a/overlays/spm/frontend/package.json +++ b/overlays/spm/frontend/package.json @@ -15,16 +15,21 @@ "@angular/common": "~13.3.0", "@angular/compiler": "~13.3.0", "@angular/core": "~13.3.0", + "@angular/flex-layout": "^13.0.0-beta.38", "@angular/forms": "~13.3.0", "@angular/material": "13.3.7", "@angular/platform-browser": "~13.3.0", "@angular/platform-browser-dynamic": "~13.3.0", "@angular/router": "~13.3.0", "@fontsource/roboto": "^4.5.7", + "@fontsource/source-sans-pro": "^4.5.10", "@material-icons/svg": "^1.0.28", + "@types/uuid": "^8.3.4", + "jwt-decode": "^3.1.2", "ngx-webstorage": "9.0.0", "rxjs": "~7.5.0", "tslib": "^2.3.0", + "uuid": "^8.3.2", "zone.js": "~0.11.4" }, "devDependencies": { 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 @@ import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; - -import { SpmComponent } from './spm/spm.component'; -import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; +import { RouterModule, Routes, PreloadAllModules } from '@angular/router'; const routes: Routes = [ - { path: 'spm', component: SpmComponent }, + { path: 'spm', loadChildren: () => import('./spm/spm.module').then(m => m.SpmModule) }, { path: '', redirectTo: '/spm', pathMatch: 'full' }, - { path: '**', component: PageNotFoundComponent } + { path: '**', loadChildren: () => import('./page-not-found/page-not-found.module').then(m => m.PageNotFoundModule) } ]; @NgModule({ - imports: [RouterModule.forRoot(routes)], + imports: [RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })], exports: [RouterModule] }) 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 @@ - -
- spm -
-
- - +
+ +
+ +
+
+ spm +
+
+ + +
+
+ +
+
+
+
+ +
+
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 @@ +.content + 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 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; +import { Component, OnInit, OnDestroy, Inject, Renderer2, RendererFactory2 } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { MediaMatcher } from '@angular/cdk/layout'; +import { LocalStorage } from 'ngx-webstorage'; +import { BehaviorSubject, Subscription, Subject } from 'rxjs'; +import { Router, RouterEvent, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { ColorSchemeService } from './color-scheme.service'; +import { map, filter } from 'rxjs/operators'; + +type ColorScheme = 'dark' | 'light'; + +export interface DomainResponse { + domain: string; +} @Component({ selector: 'app-root', @@ -8,14 +20,81 @@ import { ColorSchemeService } from './color-scheme.service'; styleUrls: ['./app.component.sass'] }) export class AppComponent implements OnInit, OnDestroy { + private renderer: Renderer2; + colorSchemeMatcher: MediaQueryList; + title = 'spm-frontend'; - constructor(private colorSchemeService: ColorSchemeService) {} + public activeColorScheme$: BehaviorSubject = new BehaviorSubject('dark'); + + @LocalStorage('prefers-color-scheme') + public colorSchemeOverride?: ColorScheme; + + colorSchemeSubscription?: Subscription; + + router: Router; + public routerLoading$: Subject = new Subject(); + routerEventSubscription?: Subscription; + + http: HttpClient; + apiDomain$: Subject = new Subject(); + + constructor( rendererFactory: RendererFactory2, + mediaMatcher: MediaMatcher, + @Inject(DOCUMENT) private document: Document, + router: Router, + http: HttpClient, + ) { + this.renderer = rendererFactory.createRenderer(null, null); + this.colorSchemeMatcher = mediaMatcher.matchMedia('(prefers-color-scheme: dark)'); + this.document = document; + + this.colorSchemeListener = this.colorSchemeListener.bind(this); + this.colorSchemeMatcher.addEventListener('change', this.colorSchemeListener); + this.activeColorScheme$.next(this.getColorScheme()); + + this.router = router; + this.http = http; + } + + private colorSchemeListener(event: { matches: boolean; }) { + this.activeColorScheme$.next(this.getColorScheme(event)); + } + + private getColorScheme(event?: { matches: boolean; }): ColorScheme { + if (this.colorSchemeOverride) { + return this.colorSchemeOverride; + } else if (event) { + return event.matches ? 'dark' : 'light'; + } else { + return this.colorSchemeMatcher.matches ? 'dark' : 'light'; + } + } + + updateColorScheme(scheme: ColorScheme) { + this.colorSchemeOverride = scheme; + this.activeColorScheme$.next(scheme); + } ngOnInit() { - this.colorSchemeService.init(); + this.colorSchemeSubscription = this.activeColorScheme$.subscribe(scheme => { this.renderer.setAttribute(this.document.body, 'data-color-scheme', scheme); }); + + this.routerEventSubscription = this.router.events.pipe( + filter(event => + event instanceof NavigationStart || + event instanceof NavigationEnd || + event instanceof NavigationCancel || + event instanceof NavigationError + ), + map(event => event instanceof NavigationStart) + ).subscribe(this.routerLoading$); + + this.http.get('/domain', { headers: new HttpHeaders({ 'Accept': 'application/json' }) }).pipe(map((resp: DomainResponse) => resp.domain)).subscribe(this.apiDomain$); } ngOnDestroy() { - this.colorSchemeService.destroy(); + this.colorSchemeMatcher.removeEventListener('change', this.colorSchemeListener); + + this.colorSchemeSubscription?.unsubscribe(); + this.routerEventSubscription?.unsubscribe(); } } 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 @@ import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; +import { NgxWebstorageModule } from 'ngx-webstorage'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; -import { SpmComponent } from './spm/spm.component'; -import { PageNotFoundComponent } from './page-not-found/page-not-found.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { MatIconModule } from '@angular/material/icon'; import { HttpClientModule } from "@angular/common/http"; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FlexLayoutModule } from "@angular/flex-layout"; @NgModule({ declarations: [ AppComponent, - SpmComponent, - PageNotFoundComponent ], imports: [ BrowserModule, + NgxWebstorageModule.forRoot(), AppRoutingModule, BrowserAnimationsModule, - MatIconModule, HttpClientModule, MatToolbarModule, - MatButtonModule + MatButtonModule, + MatProgressSpinnerModule, + FlexLayoutModule, ], providers: [], 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 @@ -import { Injectable, Renderer2, RendererFactory2 } from '@angular/core'; -import { MediaMatcher } from '@angular/cdk/layout'; -import { LocalStorage } from 'ngx-webstorage'; - -type ColorScheme = "dark" | "light"; - -@Injectable({ - providedIn: 'root' -}) -export class ColorSchemeService { - private renderer: Renderer2; - matcher!: MediaQueryList; - - @LocalStorage('prefers-color-scheme') - public colorSchemeOverride: ColorScheme; - - public activeColorScheme: BehaviorSubject = new BehaviorSubject("light"); - - constructor(rendererFactory: RendererFactory2, - mediaMatcher: MediaMatcher - ) { - // Create new renderer from renderFactory, to make it possible to use renderer2 in a service - this.renderer = rendererFactory.createRenderer(null, null); - this.matcher = mediaMatcher.matchMedia('(prefers-color-scheme: dark)'); - - this._listener = this._listener.bind(this); - } - - init() { - this.matcher.addEventListener('change', this._listener); - this.load(); - } - - _listener(event: { matches: any; }) { - this.activeColorScheme.next(event.matches ? 'dark' : 'light'); - } - - destroy() { - this.matcher.removeEventListener('change', this._listener); - } - - _getColorScheme() { - if (this.colorSchemeOverride) { - this.colorSchemeOverride - } else { - this.matcher.matches ? 'dark' : 'light' - } - } - - load() { - this.activeColorScheme.next(this._getColorScheme()); - } - - update(scheme: ColorScheme) { - this.colorSchemeOverride = scheme; - this.activeColorScheme.next(scheme); - } - - currentActive() { - return this.colorScheme; - } -} 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 @@ +import { NgModule } from '@angular/core'; +import { PageNotFoundComponent } from './page-not-found.component'; +import { RouterModule } from '@angular/router'; + +@NgModule({ + declarations: [PageNotFoundComponent], + exports: [PageNotFoundComponent], + imports: [ + RouterModule.forChild([{ + path: '', + pathMatch: 'full', + component: PageNotFoundComponent + }]), + ] +}) +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 @@ -

spm works!

+
+ + + {{entry.value.local}} + @{{entry.value.domain}} + + + + + + + + + + + + + + + +
- + 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 @@ +#add-button + position: fixed + bottom: 16px + right: 16px + +#mail-panel-container + margin-bottom: 88px + +.mat-card + overflow: hidden + +.mat-card-footer + 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 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, OnDestroy } from '@angular/core'; import { MatIconRegistry, MatIconModule } from '@angular/material/icon'; import { DomSanitizer } from "@angular/platform-browser"; +import { BehaviorSubject, interval, Subscription, catchError, throwError } from 'rxjs'; +import { HttpClient, HttpHeaders, HttpErrorResponse, HttpResponse } from '@angular/common/http'; + +import { v4 as uuidv4 } from 'uuid'; +import jwtDecode, { JwtPayload } from "jwt-decode"; + +import { map, withLatestFrom } from 'rxjs/operators'; + +export interface LoadingSpmMail { + state: "loading"; +} + +export interface LoadedSpmMail { + state: "loaded" | "claiming"; + local: string; + domain: string; + jwt: string; + start?: Date; + expiration?: Date; + percent_expiration?: number; +} + +export interface ExpiredSpmMail { + state: "expired"; + local: string; + domain: string; +} + +export interface ClaimedSpmMail { + state: "claimed"; + local: string; + domain: string; +} + +export interface SpmJwtUnregisteredPayload { + local: string; +} + +type SpmJwtPayload = JwtPayload & SpmJwtUnregisteredPayload; + +export type SpmMail = LoadingSpmMail | LoadedSpmMail | ClaimedSpmMail | ExpiredSpmMail; + +function percentExpiration(start: Date, exp: Date, now?: Date) { + if (!now) { + now = new Date(); + } + + if (now < start) { + return 100; + } else if (exp < now) { + return 0; + } else { + const length = exp.getTime() - start.getTime(); + const diff = exp.getTime() - now.getTime(); + return 100 * (diff / length); + } +} + +function updateSpmExpiration(value: SpmMail): SpmMail { + if (value.state === 'loaded') { + const percent_expiration = value.expiration && value.start ? percentExpiration(value.start, value.expiration) : undefined; + if (percent_expiration !== undefined && percent_expiration <= 0) { + return {state: "expired", local: value.local, domain: value.domain}; + } else { + return {...value, percent_expiration: percent_expiration} + } + } else { + return value; + } +} @Component({ selector: 'app-spm', templateUrl: './spm.component.html', styleUrls: ['./spm.component.sass'] }) -export class SpmComponent implements OnInit { +export class SpmComponent implements OnInit, OnDestroy { + spmMails$: BehaviorSubject> = new BehaviorSubject(new Map()); + http: HttpClient; + + intervalSubscription?: Subscription; constructor(private matIconRegistry: MatIconRegistry, - private domSanitizer: DomSanitizer + private domSanitizer: DomSanitizer, + http: HttpClient, ) { this.matIconRegistry.addSvgIcon( - `thumb_up`, - this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/thumb_up/baseline.svg`) + `add`, + this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/add/baseline.svg`) + ); + this.matIconRegistry.addSvgIcon( + `check`, + this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/check/baseline.svg`) + ); + this.matIconRegistry.addSvgIcon( + `delete_forever`, + this.domSanitizer.bypassSecurityTrustResourceUrl(`icons/delete_forever/baseline.svg`) ); + + this.http = http; + } + + add() { + const ident = uuidv4(); + const curr: Map = this.spmMails$.getValue(); + curr.set(ident, { state: 'loading' }); + this.spmMails$.next(curr); + 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)) + } + + load(k: string, encoded: string) { + const payload = jwtDecode(encoded); + if (typeof payload.aud === 'string') { + const curr: Map = this.spmMails$.getValue(); + curr.set(k, { + state: 'loaded', + local: payload.local, + domain: payload.aud, + jwt: encoded, + expiration: payload.exp ? new Date(1000 * payload.exp) : undefined, + start: payload.nbf ? new Date(1000 * payload.nbf) : undefined, + percent_expiration: payload.exp && payload.nbf ? percentExpiration(new Date(1000 * payload.nbf), new Date(1000 * payload.exp)) : undefined + }); + this.spmMails$.next(curr); + } + } + + handleClaimError(k: string, error: HttpErrorResponse) { + this.setState(k, 'loaded'); + + return throwError(() => new Error('Claiming failed.')); + } + + claim(k: string) { + const val = this.spmMails$.getValue().get(k); + if (!val || val.state !== 'loaded') + return; + + this.setState(k, 'claiming'); + + 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)) } - ngOnInit(): void { + setState(k: string, state: 'loaded' | 'claiming') { + const curr: Map = this.spmMails$.getValue(); + const val = curr.get(k); + if (!val || (val.state !== 'loaded' && val.state !== 'claiming')) + return; + + curr.set(k, {...val, state: state}); + this.spmMails$.next(curr); + } + + claimed(k: string, response: HttpResponse) { + if (response.status !== 204) { + this.setState(k, 'loaded'); + return; + } + + const curr: Map = this.spmMails$.getValue(); + const val = curr.get(k); + if (val && (val.state === 'claiming' || val.state === 'loaded')) { + curr.set(k, {state: "claimed", local: val.local, domain: val.domain}); + } + this.spmMails$.next(curr); } + forget(k: string) { + const curr: Map = this.spmMails$.getValue(); + curr.delete(k); + this.spmMails$.next(curr); + } + + stepSpmMails(curr: Map) { + curr.forEach((value, key) => curr.set(key, updateSpmExpiration(value))); + this.spmMails$.next(curr); + } + + ngOnInit() { + this.intervalSubscription = interval(1000).pipe(withLatestFrom(this.spmMails$)).subscribe(([_, mails]: [number, Map]) => this.stepSpmMails(mails)); + } + + ngOnDestroy() { + this.intervalSubscription?.unsubscribe(); + } + + asIsOrder(a: any, b: any) { + return -1; + } } 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 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SpmComponent } from './spm.component'; +import { RouterModule } from '@angular/router'; + +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { FlexLayoutModule } from "@angular/flex-layout"; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; + +@NgModule({ + declarations: [SpmComponent], + exports: [SpmComponent], + imports: [ + RouterModule.forChild([{ + path: '', + pathMatch: 'full', + component: SpmComponent + }]), + MatIconModule, + MatButtonModule, + MatCardModule, + CommonModule, + FlexLayoutModule, + MatProgressSpinnerModule, + MatProgressBarModule, + ] +}) +export 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(( html, body { height: 100%; } body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } + +.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 @@ /* You can add global styles to this file, and also import other style files */ @import @fontsource/roboto +@import @fontsource/source-sans-pro diff --git a/overlays/spm/frontend/yarn-project.nix b/overlays/spm/frontend/yarn-project.nix index a070e3f2..07017812 100644 --- a/overlays/spm/frontend/yarn-project.nix +++ b/overlays/spm/frontend/yarn-project.nix @@ -120,17 +120,21 @@ let cacheEntries = { "@fontsource/roboto@npm:4.5.7" = { filename = "@fontsource-roboto-npm-4.5.7-315fd54028-3f9afc7f4c.zip"; sha512 = "3f9afc7f4c77e517e804e65823db5aea2513e8352fbc26cf7ea8b8b11e7dc6eff48d7a2855ed71e3a6b727446b2bf137629e2389fe3d676be45d95dd9992b558"; }; +"@fontsource/source-sans-pro@npm:4.5.10" = { filename = "@fontsource-source-sans-pro-npm-4.5.10-520cde2259-d0b786cdff.zip"; sha512 = "d0b786cdff56e2b9f0dbb557473342ac95bc2021eea57097a29244936554a1f452b1ba5b053f11f2f3bcf58ba77823ac1e2a3ff8aba8c4618026a131a025a86d"; }; "@material-icons/svg@npm:1.0.28" = { filename = "@material-icons-svg-npm-1.0.28-205466118b-c40b15422b.zip"; sha512 = "c40b15422b0d74ef9bb6ac14a8e7669a435d12bfcc8ab2fb7c986a95a37dad1abaac6e98d52b195b7643f982f7e5f21413c5c730ba5e3c810f0c04f1da068af7"; }; "@types/jasmine@npm:3.10.6" = { filename = "@types-jasmine-npm-3.10.6-bc64f97df3-dff2c26a9e.zip"; sha512 = "dff2c26a9ecbc8198d2f5bf1860275b0b323c80db772c2417d7217afa28bd05fee4b98ab3673dcb18d859f2cd0e084b0dcbd33629fcbe6945b08a72f8e5c36d2"; }; "@types/node@npm:12.20.52" = { filename = "@types-node-npm-12.20.52-be1a44f9b8-b720ee939a.zip"; sha512 = "b720ee939a43f1427baf1606deee97e50892ff482bf3d64ab8a1eda3a50802a80866a232aeab337621f6ea4fdc4515147d7829337653e9e99d97c3aaeec3615d"; }; +"@types/uuid@npm:8.3.4" = { filename = "@types-uuid-npm-8.3.4-7547f4402c-6f11f3ff70.zip"; sha512 = "6f11f3ff70f30210edaa8071422d405e9c1d4e53abbe50fdce365150d3c698fe7bbff65c1e71ae080cbfb8fded860dbb5e174da96fdbbdfcaa3fb3daa474d20f"; }; "jasmine-core@npm:4.0.1" = { filename = "jasmine-core-npm-4.0.1-e72087e57c-f097bf2c68.zip"; sha512 = "f097bf2c681acb55e03fb56a1569a9dfa8fcdf7c291aeb8786cb693c3a803ba2a59d6bac4c1facd239d778cc6f643d0a78920d9d278cba5d0cd46779b2546822"; }; "karma-jasmine-html-reporter@npm:1.7.0" = { filename = "karma-jasmine-html-reporter-npm-1.7.0-87aa5c7cce-926c25858e.zip"; sha512 = "926c25858ea45115496524749881837de0091e3f8119e0f81a18c377e69fb82f832836b94e08d2a24fbc49aa21b491ae0bb861f07404f144c92bc84f409ef202"; }; "tslib@npm:2.4.0" = { filename = "tslib-npm-2.4.0-9cb6dc5030-8c4aa6a3c5.zip"; sha512 = "8c4aa6a3c5a754bf76aefc38026134180c053b7bd2f81338cb5e5ebf96fefa0f417bff221592bf801077f5bf990562f6264fecbc42cd3309b33872cb6fc3b113"; }; +"uuid@npm:8.3.2" = { filename = "uuid-npm-8.3.2-eca0baba53-5575a8a75c.zip"; sha512 = "5575a8a75c13120e2f10e6ddc801b2c7ed7d8f3c8ac22c7ed0c7b2ba6383ec0abda88c905085d630e251719e0777045ae3236f04c812184b7c765f63a70e58df"; }; "typescript@npm:4.6.4" = { filename = "typescript-npm-4.6.4-114dfa5f7e-e7bfcc39cd.zip"; sha512 = "e7bfcc39cd4571a63a54e5ea21f16b8445268b9900bf55aee0e02ad981be576acc140eba24f1af5e3c1457767c96cea6d12861768fb386cf3ffb34013718631a"; }; "@angular/animations@npm:13.3.9" = { filename = "@angular-animations-npm-13.3.9-e1d514c5f5-4c77f5678a.zip"; sha512 = "4c77f5678acc88cc6cba6e76c498bc0951dbcba2f985eded4972f830857def10918551a805882017655df8dbc8c39821ee8a5ca295d0abf7d82746799aa7f212"; }; "@angular/common@npm:13.3.9" = { filename = "@angular-common-npm-13.3.9-9a7d731ff5-984af09c8a.zip"; sha512 = "984af09c8af87d78a85f743e6923c047c032489a97dd93b8d10702e7556cb842b63a2c836c339f418fe73cc0cd0ce50e262a27a5c721cd8bec35279376625219"; }; "@angular/compiler@npm:13.3.9" = { filename = "@angular-compiler-npm-13.3.9-06a00a359f-4e80398d61.zip"; sha512 = "4e80398d6121b70961ec3a7565ba897dcd63788b78c4011817fd7d3114a398f29c79f5ca985bffcc1e7e11a57dc89054e5cfef410720aacb9b9368647f452798"; }; "@angular/core@npm:13.3.9" = { filename = "@angular-core-npm-13.3.9-8bfa1070cd-4b4ce78de4.zip"; sha512 = "4b4ce78de4aadfb6f5e2d8417157d60337052c02608137e5a6548d8e6a8ef9bb4edd29187a2b8ea2720527c20731797b5d0d97ab800e580ccc5e5b609caf80c5"; }; +"@angular/flex-layout@npm:13.0.0-beta.38" = { filename = "@angular-flex-layout-npm-13.0.0-beta.38-a6255273fa-c5f22d6748.zip"; sha512 = "c5f22d6748ad8dc2ebc9fa57c82d80cb9f9942d66dfb0246315d351872a2c9df5fa95d849fb0d2750ca99812d5e27d073b7842e07e6f21e541cbe337d9f4e7ed"; }; "@angular/forms@npm:13.3.9" = { filename = "@angular-forms-npm-13.3.9-bd7c125354-7d95a7c482.zip"; sha512 = "7d95a7c4828f84be28882320ce4ea685b7990bb0dd920417ce3285d4ad61231b546c27e75b8fc90c4a24629c1cb5ae7692af59f2d9992887672ee3461027683d"; }; "@angular/material@npm:13.3.7" = { filename = "@angular-material-npm-13.3.7-5b8dc87806-a98ea8117e.zip"; sha512 = "a98ea8117e381412d77f972247f56b87542c01e97964ef6b6d369df5c0a9bc3d247886ad22b235a4e5a471f20e1724297036c9acfcdd2d03edaa4bedd8f7bbf8"; }; "@angular/platform-browser-dynamic@npm:13.3.9" = { filename = "@angular-platform-browser-dynamic-npm-13.3.9-3414000d2b-34967ed4fd.zip"; sha512 = "34967ed4fd6b81bacdef17432334e6c99656936d0f4625d8fae854f0984868e6ce0b5c497fb028c68971d09eaf85080ce201ee023c803c1126970a2a831528cf"; }; @@ -138,9 +142,11 @@ cacheEntries = { "@angular/router@npm:13.3.9" = { filename = "@angular-router-npm-13.3.9-c3b3c739a8-e675ff9a3e.zip"; sha512 = "e675ff9a3e33c92041c897601d633372a3cfaf502d4779b01469f1f26798d0bdf9b2b411082fe64d997bda0c75f7f12fbb32013286447b1398bc70565df028cb"; }; "karma-chrome-launcher@npm:3.1.1" = { filename = "karma-chrome-launcher-npm-3.1.1-871ed2dfa7-8442219105.zip"; sha512 = "8442219105e1f11a9284fd47f2e21e34720f7e725f25ea08f7525a7ec2088e2c1b65e2def4d7780139d296afc5c30bf4e1d4a839a097eb814031c2f6b379b39f"; }; "karma-jasmine@npm:4.0.2" = { filename = "karma-jasmine-npm-4.0.2-8647c711ec-bf884704af.zip"; sha512 = "bf884704af1fd19816d9f4e96b25e286ff1a57adcabe1f15e3d2b3e9c1da873c1c843b9eab4274c27e63a99f1c3dea864f1f5eca1a10dc065e6e9d5796c207b4"; }; +"ngx-webstorage@npm:9.0.0" = { filename = "ngx-webstorage-npm-9.0.0-64bf01f819-988ecf069d.zip"; sha512 = "988ecf069d3bdf2c8108cc3e011410eb2e951b6b7932ceddcd5bb1141d42cabd62be97e2d248dd495d5ce4917934ef866a431a0afecd8d90de1040978a933b82"; }; "rxjs@npm:7.5.5" = { filename = "rxjs-npm-7.5.5-d0546b1ccb-e034f60805.zip"; sha512 = "e034f60805210cce756dd2f49664a8108780b117cf5d0e2281506e9e6387f7b4f1532d974a8c8b09314fa7a16dd2f6cff3462072a5789672b5dcb45c4173f3c6"; }; "zone.js@npm:0.11.5" = { filename = "zone.js-npm-0.11.5-78b46cd237-7dba3af83c.zip"; sha512 = "7dba3af83cc68e881e8a5cc549e4a7bab5f32c5289838f14eb251871816210a39ed8bcd89030593b56d469b4aa8271d644e71ad408fcadd4ccd61b8c7203f2ef"; }; "@angular/cdk@npm:13.3.7" = { filename = "@angular-cdk-npm-13.3.7-58a23cc0a3-a2944da577.zip"; sha512 = "a2944da5774a67956fe48762748b2e24dbd58be760652aca5c22fcfa67ad3e01635f6890c689596fe1525d6dbb2a7bd46ed616bdc4d9ee05db64d849b410cbf9"; }; +"jwt-decode@npm:3.1.2" = { filename = "jwt-decode-npm-3.1.2-bf3ab26591-20a4b072d4.zip"; sha512 = "20a4b072d44ce3479f42d0d2c8d3dabeb353081ba4982e40b83a779f2459a70be26441be6c160bfc8c3c6eadf9f6380a036fbb06ac5406b5674e35d8c4205eeb"; }; "jasmine-core@npm:3.99.1" = { filename = "jasmine-core-npm-3.99.1-07f52d92cc-4e4a89739d.zip"; sha512 = "4e4a89739d99e471b86c7ccc4c5c244a77cc6d1e17b2b0d87d81266b8415697354d8873f7e764790a10661744f73a753a6e9bcd9b3e48c66a0c9b8a092b071b7"; }; "parse5@npm:5.1.1" = { filename = "parse5-npm-5.1.1-8e63d82cff-613a714af4.zip"; sha512 = "613a714af4c1101d1cb9f7cece2558e35b9ae8a0c03518223a4a1e35494624d9a9ad5fad4c13eab66a0e0adccd9aa3d522fc8f5f9cc19789e0579f3fa0bdfc65"; }; "which@npm:1.3.1" = { filename = "which-npm-1.3.1-f0ebb8bdd8-f2e185c624.zip"; sha512 = "f2e185c6242244b8426c9df1510e86629192d93c1a986a7d2a591f2c24869e7ffd03d6dac07ca863b2e4c06f59a4cc9916c585b72ee9fa1aa609d0124df15e04"; }; @@ -182,7 +188,6 @@ cacheEntries = { "ini@npm:2.0.0" = { filename = "ini-npm-2.0.0-28f7426761-e7aadc5fb2.zip"; sha512 = "e7aadc5fb2e4aefc666d74ee2160c073995a4061556b1b5b4241ecb19ad609243b9cceafe91bae49c219519394bbd31512516cb22a3b1ca6e66d869e0447e84e"; }; "jsonc-parser@npm:3.0.0" = { filename = "jsonc-parser-npm-3.0.0-66e692e88a-1df2326f1f.zip"; sha512 = "1df2326f1f9688de30c70ff19c5b2a83ba3b89a1036160da79821d1361090775e9db502dc57a67c11b56e1186fc1ed70b887f25c5febf9a3ec4f91435836c99d"; }; "symbol-observable@npm:4.0.0" = { filename = "symbol-observable-npm-4.0.0-5c36594410-212c7edce6.zip"; sha512 = "212c7edce6186634d671336a88c0e0bbd626c2ab51ed57498dc90698cce541839a261b969c2a1e8dd43762133d47672e8b62e0b1ce9cf4157934ba45fd172ba8"; }; -"uuid@npm:8.3.2" = { filename = "uuid-npm-8.3.2-eca0baba53-5575a8a75c.zip"; sha512 = "5575a8a75c13120e2f10e6ddc801b2c7ed7d8f3c8ac22c7ed0c7b2ba6383ec0abda88c905085d630e251719e0777045ae3236f04c812184b7c765f63a70e58df"; }; "debug@npm:4.3.3" = { filename = "debug-npm-4.3.3-710fd4cc7f-14472d56fe.zip"; sha512 = "14472d56fe4a94dbcfaa6dbed2dd3849f1d72ba78104a1a328047bb564643ca49df0224c3a17fa63533fd11dd3d4c8636cd861191232a2c6735af00cc2d4de16"; }; "semver@npm:7.3.5" = { filename = "semver-npm-7.3.5-618cf5db6a-5eafe6102b.zip"; sha512 = "5eafe6102bea2a7439897c1856362e31cc348ccf96efd455c8b5bc2c61e6f7e7b8250dc26b8828c1d76a56f818a7ee907a36ae9fb37a599d3d24609207001d60"; }; "yallist@npm:4.0.0" = { filename = "yallist-npm-4.0.0-b493d9e907-343617202a.zip"; sha512 = "343617202af32df2a15a3be36a5a8c0c8545208f3d3dfbc6bb7c3e3b7e8c6f8e7485432e4f3b88da3031a6e20afa7c711eded32ddfb122896ac5d914e75848d5"; }; @@ -984,7 +989,6 @@ cacheEntries = { "jsesc@npm:0.5.0" = { filename = "jsesc-npm-0.5.0-6827074492-b8b44cbfc9.zip"; sha512 = "b8b44cbfc92f198ad972fba706ee6a1dfa7485321ee8c0b25f5cedd538dcb20cde3197de16a7265430fce8277a12db066219369e3d51055038946039f6e20e17"; }; "unicode-canonical-property-names-ecmascript@npm:2.0.0" = { filename = "unicode-canonical-property-names-ecmascript-npm-2.0.0-d2d8554a14-39be078afd.zip"; sha512 = "39be078afd014c14dcd957a7a46a60061bc37c4508ba146517f85f60361acf4c7539552645ece25de840e17e293baa5556268d091ca6762747fdd0c705001a45"; }; "unicode-property-aliases-ecmascript@npm:2.0.0" = { filename = "unicode-property-aliases-ecmascript-npm-2.0.0-1636cb7768-dda4d39128.zip"; sha512 = "dda4d39128cbbede2ac60fbb85493d979ec65913b8a486bf7cb7a375a2346fa48cbf9dc6f1ae23376e7e8e684c2b411434891e151e865a661b40a85407db51d0"; }; -"ngx-webstorage@npm:9.0.0" = { filename = "ngx-webstorage-npm-9.0.0-64bf01f819-988ecf069d.zip"; sha512 = "988ecf069d3bdf2c8108cc3e011410eb2e951b6b7932ceddcd5bb1141d42cabd62be97e2d248dd495d5ce4917934ef866a431a0afecd8d90de1040978a933b82"; }; }; in optionalOverride overrideAttrs project diff --git a/overlays/spm/frontend/yarn.lock b/overlays/spm/frontend/yarn.lock index 21ace08d..9bd93e91 100644 --- a/overlays/spm/frontend/yarn.lock +++ b/overlays/spm/frontend/yarn.lock @@ -283,6 +283,21 @@ __metadata: languageName: node linkType: hard +"@angular/flex-layout@npm:^13.0.0-beta.38": + version: 13.0.0-beta.38 + resolution: "@angular/flex-layout@npm:13.0.0-beta.38" + dependencies: + tslib: ^2.3.0 + peerDependencies: + "@angular/cdk": ^13.0.0 + "@angular/common": ^13.0.0 + "@angular/core": ^13.0.0 + "@angular/platform-browser": ^13.0.0 + rxjs: ^6.5.3 || ^7.4.0 + checksum: c5f22d6748ad8dc2ebc9fa57c82d80cb9f9942d66dfb0246315d351872a2c9df5fa95d849fb0d2750ca99812d5e27d073b7842e07e6f21e541cbe337d9f4e7ed + languageName: node + linkType: hard + "@angular/forms@npm:~13.3.0": version: 13.3.9 resolution: "@angular/forms@npm:13.3.9" @@ -1710,6 +1725,13 @@ __metadata: languageName: node linkType: hard +"@fontsource/source-sans-pro@npm:^4.5.10": + version: 4.5.10 + resolution: "@fontsource/source-sans-pro@npm:4.5.10" + checksum: d0b786cdff56e2b9f0dbb557473342ac95bc2021eea57097a29244936554a1f452b1ba5b053f11f2f3bcf58ba77823ac1e2a3ff8aba8c4618026a131a025a86d + languageName: node + linkType: hard + "@gar/promisify@npm:^1.0.1, @gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -2164,6 +2186,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^8.3.4": + version: 8.3.4 + resolution: "@types/uuid@npm:8.3.4" + checksum: 6f11f3ff70f30210edaa8071422d405e9c1d4e53abbe50fdce365150d3c698fe7bbff65c1e71ae080cbfb8fded860dbb5e174da96fdbbdfcaa3fb3daa474d20f + languageName: node + linkType: hard + "@types/ws@npm:^8.2.2": version: 8.5.3 resolution: "@types/ws@npm:8.5.3" @@ -5457,6 +5486,13 @@ __metadata: languageName: node linkType: hard +"jwt-decode@npm:^3.1.2": + version: 3.1.2 + resolution: "jwt-decode@npm:3.1.2" + checksum: 20a4b072d44ce3479f42d0d2c8d3dabeb353081ba4982e40b83a779f2459a70be26441be6c160bfc8c3c6eadf9f6380a036fbb06ac5406b5674e35d8c4205eeb + languageName: node + linkType: hard + "karma-chrome-launcher@npm:~3.1.0": version: 3.1.1 resolution: "karma-chrome-launcher@npm:3.1.1" @@ -8083,16 +8119,20 @@ __metadata: "@angular/compiler": ~13.3.0 "@angular/compiler-cli": ~13.3.0 "@angular/core": ~13.3.0 + "@angular/flex-layout": ^13.0.0-beta.38 "@angular/forms": ~13.3.0 "@angular/material": 13.3.7 "@angular/platform-browser": ~13.3.0 "@angular/platform-browser-dynamic": ~13.3.0 "@angular/router": ~13.3.0 "@fontsource/roboto": ^4.5.7 + "@fontsource/source-sans-pro": ^4.5.10 "@material-icons/svg": ^1.0.28 "@types/jasmine": ~3.10.0 "@types/node": ^12.11.1 + "@types/uuid": ^8.3.4 jasmine-core: ~4.0.0 + jwt-decode: ^3.1.2 karma: ~6.3.0 karma-chrome-launcher: ~3.1.0 karma-coverage: ~2.1.0 @@ -8102,6 +8142,7 @@ __metadata: rxjs: ~7.5.0 tslib: ^2.3.0 typescript: ~4.6.2 + uuid: ^8.3.2 zone.js: ~0.11.4 languageName: unknown linkType: soft diff --git a/overlays/spm/lib/Spm/Api.hs b/overlays/spm/lib/Spm/Api.hs index 14acfac4..c44a7951 100644 --- a/overlays/spm/lib/Spm/Api.hs +++ b/overlays/spm/lib/Spm/Api.hs @@ -2,7 +2,7 @@ module Spm.Api ( SpmStyle(..), _SpmWords, _SpmConsonants - , SpmMailbox + , SpmMailbox, SpmDomain , SpmApi, spmApi ) where @@ -30,6 +30,8 @@ import Crypto.JWT.Instances () import Data.UUID (UUID) import Data.UUID.Instances () +import qualified Data.Aeson as JSON + -- import Data.Aeson (ToJSON, FromJSON) @@ -50,8 +52,20 @@ instance FromHttpApiData SpmStyle where newtype SpmMailbox = SpmMailbox { unSpmMailbox :: CI Text } deriving stock (Eq, Ord, Read, Show, Generic, Typeable) - deriving newtype (MimeRender JSON, MimeRender PlainText) + deriving newtype (MimeRender PlainText) makeWrapped ''SpmMailbox + +instance MimeRender JSON SpmMailbox where + mimeRender p mbox = mimeRender p $ JSON.object [ "mailbox" JSON..= unSpmMailbox mbox ] + +newtype SpmDomain = SpmDomain { unSpmDomain :: CI Text } + deriving stock (Eq, Ord, Read, Show, Generic, Typeable) + deriving newtype (MimeRender PlainText) +makeWrapped ''SpmDomain + +instance MimeRender JSON SpmDomain where + mimeRender p dom = mimeRender p $ JSON.object [ "domain" JSON..= unSpmDomain dom ] + -- newtype SpmLocal = SpmLocal -- { unSpmLocal :: CI Text -- } deriving stock (Eq, Ord, Read, Show, Generic, Typeable) @@ -79,6 +93,7 @@ makeWrapped ''SpmMailbox -- ] type SpmApi = "whoami" :> Get '[PlainText, JSON] SpmMailbox + :<|> "domain" :> Get '[PlainText, JSON] SpmDomain :<|> "jwks.json" :> Get '[JSON] JWKSet :<|> "instance-id" :> Get '[PlainText, JSON, OctetStream] UUID :<|> "spm" :> "generate" :> QueryParam "style" SpmStyle :> Get '[PlainText, JSON, OctetStream] SignedJWT diff --git a/overlays/spm/server/Spm/Server.hs b/overlays/spm/server/Spm/Server.hs index 1f785999..0dd3e810 100644 --- a/overlays/spm/server/Spm/Server.hs +++ b/overlays/spm/server/Spm/Server.hs @@ -114,6 +114,7 @@ type SpmServerApi = Header' '[Required, Strict] "SPM-Domain" MailDomain :> AuthProtect "spm_mailbox" :> SpmApi :<|> "ui" :> Raw + :<|> GetNoContent spmServerApi :: Proxy SpmServerApi spmServerApi = Proxy @@ -183,11 +184,15 @@ mkSpmApp = do spmServer' = spmServer :<|> Tagged uiServer + :<|> uiRedirect logger <- askLoggerIO return $ serveWithContextT spmServerApi spmServerContext ((runReaderT ?? ServerCtx{..}) . hoist (runLoggingT ?? logger)) spmServer' & requestLogger + where + uiRedirect = throwError err302 { errHeaders = [("Location", "/ui")] } + spmSql :: ReaderT SqlBackend Handler' a -> Handler' a spmSql act = do sqlPool <- view sctxSqlPool @@ -208,15 +213,18 @@ generateLocal SpmConsonants = fmap (review _Wrapped . CI.mk) . liftIO $ do spmServer :: MailDomain -> MailMailbox -> Server' SpmApi spmServer dom mbox = whoami - :<|> jwkSet - :<|> instanceId - :<|> generate - :<|> claim + :<|> domain + :<|> jwkSet + :<|> instanceId + :<|> generate + :<|> claim where whoami = do Entity _ Mailbox{mailboxIdent} <- maybe (throwError err404) return <=< spmSql . getBy $ UniqueMailbox mbox return $ mailboxIdent ^. _Wrapped . re _Wrapped + domain = return $ dom ^. _Wrapped . re _Wrapped + jwkSet = views sctxJwkSet $ over _Wrapped (^.. folded . asPublicKey . _Just) instanceId = view sctxInstanceId -- cgit v1.2.3