feat: backend-supplied id cards on profile page

feat: SCIdCard thing
This commit is contained in:
2023-08-21 12:49:57 +02:00
parent 905ebf8c59
commit 1f62b5c5b0
30 changed files with 635 additions and 162 deletions

View File

@@ -29,6 +29,7 @@ export const DataIcons: Record<SCThingType, string> = {
'dish': SCIcon`lunch_dining`,
'favorite': SCIcon`favorite`,
'floor': SCIcon`foundation`,
'id card': SCIcon`badge`,
'message': SCIcon`newspaper`,
'organization': SCIcon`business_center`,
'periodical': SCIcon`feed`,

View File

@@ -0,0 +1,29 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {SCIdCard} from '@openstapps/core';
import {FullScreenImageDirective} from '../../util/full-screen-image.directive';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {AsyncPipe, NgIf, TitleCasePipe} from '@angular/common';
import {InRangeNowPipe, ToDateRangePipe} from '../../util/in-range.pipe';
import {TranslateModule} from '@ngx-translate/core';
@Component({
selector: 'stapps-id-card',
templateUrl: 'id-card.html',
styleUrls: ['id-card.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
hostDirectives: [FullScreenImageDirective],
imports: [
FullScreenImageDirective,
ThingTranslateModule,
NgIf,
InRangeNowPipe,
ToDateRangePipe,
AsyncPipe,
TranslateModule,
TitleCasePipe,
],
})
export class IdCardComponent {
@Input({required: true}) item: SCIdCard;
}

View File

@@ -0,0 +1,4 @@
<img [src]="item.image" [alt]="'name' | thingTranslate : item" draggable="false" />
<div *ngIf="item.validity && (item.validity | toDateRange | isInRangeNow | async) === false" class="expired">
{{ 'profile.userInfo.expired' | translate | titlecase }}
</div>

View File

@@ -0,0 +1,34 @@
:host {
position: relative;
overflow: hidden;
}
:host:fullscreen {
margin: 0;
padding: 0;
}
img {
border-radius: 3mm;
}
.expired {
position: absolute;
top: 48px;
left: 48px;
transform-origin: center;
translate: -50% -50%;
rotate: -45deg;
display: flex;
align-items: center;
justify-content: center;
width: 256px;
padding: var(--spacing-xs);
font-weight: bold;
color: var(--ion-color-danger-contrast);
background: var(--ion-color-danger);
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {IdCardsProvider} from './id-cards.provider';
import {SCIdCard} from '@openstapps/core';
import {IonicModule} from '@ionic/angular';
import {AsyncPipe, NgForOf, NgIf, TitleCasePipe} from '@angular/common';
import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {UtilModule} from '../../util/util.module';
import {FullScreenImageDirective} from '../../util/full-screen-image.directive';
import {IdCardComponent} from './id-card.component';
import {TranslateModule} from '@ngx-translate/core';
import {Observable} from 'rxjs';
@Component({
selector: 'stapps-id-cards',
templateUrl: 'id-cards.html',
styleUrls: ['id-cards.scss'],
providers: [IdCardsProvider],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
IonicModule,
NgForOf,
NgIf,
AsyncPipe,
ThingTranslateModule,
UtilModule,
FullScreenImageDirective,
IdCardComponent,
TranslateModule,
TitleCasePipe,
],
})
export class IdCardsComponent {
idCards: Observable<SCIdCard[]> = this.idCardsProvider.getIdCards();
constructor(readonly idCardsProvider: IdCardsProvider) {}
}

View File

@@ -0,0 +1,25 @@
<!--
~ Copyright (C) 2023 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<stapps-section buttonColor="primary-contrast">
<simple-swiper *ngIf="idCards | async as idCards">
<div *ngIf="idCards.length === 0">
<div class="log-in">
{{'profile.userInfo.logInPrompt' | translate | sentencecase}}
<ion-icon name="person" fill="true"></ion-icon>
</div>
</div>
<stapps-id-card *ngFor="let idCard of idCards" [item]="idCard"></stapps-id-card>
</simple-swiper>
</stapps-section>

View File

@@ -0,0 +1,74 @@
import {Injectable} from '@angular/core';
import {SCIdCard, SCThingOriginType, SCThingType, SCUserConfiguration} from '@openstapps/core';
import {from, Observable, of} from 'rxjs';
import {AuthHelperService} from '../auth/auth-helper.service';
import {mergeMap, filter, map, startWith} from 'rxjs/operators';
import {ConfigProvider} from '../config/config.provider';
import {HttpClient} from '@angular/common/http';
@Injectable({providedIn: 'root'})
export class IdCardsProvider {
constructor(
private authHelper: AuthHelperService,
private config: ConfigProvider,
private httpClient: HttpClient,
) {}
getIdCards(): Observable<SCIdCard[]> {
const feature = this.config.config.app.features.extern?.['idCards'];
const auth = this.authHelper.getProvider(feature?.authProvider ?? 'default');
return auth.isAuthenticated$.pipe(
mergeMap(isAuthenticated =>
isAuthenticated
? feature
? from(auth.getValidToken()).pipe(
mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)),
)
: auth.user$.pipe(
filter(user => user !== undefined),
map(userInfo => this.authHelper.getUserFromUserInfo(userInfo as object)),
mergeMap(user => this.fetchFallbackIdCards(user)),
startWith([]),
)
: // TODO: find a better solution here (async pipe stuff...)
of([]),
),
);
}
private fetchIdCards(url: string, token: string): Observable<SCIdCard[]> {
// eslint-disable-next-line unicorn/no-null
return this.httpClient.post(url, null, {
headers: {
Authorization: `Bearer: ${token}`,
},
responseType: 'json',
}) as Observable<SCIdCard[]>;
}
private fetchFallbackIdCards(user: SCUserConfiguration): Observable<SCIdCard[]> {
return this.httpClient.get('/assets/examples/student-id.sample.svg', {responseType: 'text'}).pipe(
map(svg => {
let result = svg;
for (const key in user) {
result = result.replaceAll(`{{${key}}`, (user as unknown as Record<string, string>)[key]);
}
return `data:image/svg+xml;base64,${Buffer.from(result, 'base64').toString('base64')}`;
}),
map(image => [
{
name: 'Student ID',
image,
type: SCThingType.IdCard,
uid: '1234',
origin: {
name: 'Sample Origin',
type: SCThingOriginType.Remote,
indexed: new Date().toISOString(),
},
},
]),
);
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (C) 2023 StApps
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
:host {
display: contents;
}
simple-swiper {
--swiper-slide-width: calc(min(90cqw, 12cm));
filter: drop-shadow(0 0 16px rgba(0 0 0 / 10%));
}
.log-in {
position: relative;
display: flex;
align-items: flex-end;
justify-content: center;
height: calc(min(90cqw, 12cm) * (53.98 / 85.6));
padding: var(--spacing-xxl);
color: var(--ion-color-medium);
background: var(--ion-color-light);
border-radius: 3mm;
outline: 1px dashed var(--ion-color-medium);
outline-offset: calc(-1 * var(--spacing-sm));
> ion-icon {
position: absolute;
top: 0;
left: 0;
font-size: calc(min(90cqw, 12cm) / 2);
opacity: 0.1;
}
}

View File

@@ -0,0 +1,65 @@
import {ConfigProvider} from '../config/config.provider';
import {IdCardsProvider} from './id-cards.provider';
import {HttpClient} from '@angular/common/http';
import {AuthHelperService} from '../auth/auth-helper.service';
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
import {SCAuthorizationProviderType} from '@openstapps/core';
class FakeAuth {
isAuthenticated$ = new BehaviorSubject(false);
// eslint-disable-next-line @typescript-eslint/no-empty-function
getValidToken() {}
}
describe('IdCards', () => {
let configProvider: ConfigProvider;
let httpClient: HttpClient;
let authHelper: AuthHelperService;
let fakeAuth: FakeAuth;
beforeEach(() => {
configProvider = jasmine.createSpyObj('ConfigProvider', ['config']);
configProvider.config = {
app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'fakeAuth'}}}},
} as never;
httpClient = jasmine.createSpyObj('HttpClient', ['post']);
fakeAuth = new FakeAuth();
authHelper = jasmine.createSpyObj('AuthHelperService', ['getProvider']);
authHelper.getProvider = jasmine.createSpy().and.returnValue(fakeAuth);
});
it('should emit undefined if not logged in', async () => {
const provider = new IdCardsProvider(authHelper, configProvider, httpClient);
expect(await firstValueFrom(provider.getIdCards())).toEqual([]);
expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType);
});
it('should emit network result when logged in', async () => {
fakeAuth.isAuthenticated$.next(true);
httpClient.post = jasmine.createSpy().and.returnValue(of(['abc']));
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
const provider = new IdCardsProvider(authHelper, configProvider, httpClient);
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
expect(authHelper.getProvider).toHaveBeenCalledOnceWith('fakeAuth' as SCAuthorizationProviderType);
// eslint-disable-next-line unicorn/no-null
expect(httpClient.post).toHaveBeenCalledOnceWith('http://id-cards.local', null, {
headers: {
Authorization: 'Bearer: fake-token',
},
responseType: 'json',
});
});
it('should react to logins', async () => {
const provider = new IdCardsProvider(authHelper, configProvider, httpClient);
const observable = provider.getIdCards();
expect(await firstValueFrom(observable)).toEqual([]);
httpClient.post = jasmine.createSpy().and.returnValue(of(['abc']));
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
fakeAuth.isAuthenticated$.next(true);
// this is counter-intuitive, but because we unsubscribed above the first value
// will now contain the network result.
expect(await firstValueFrom(observable)).toEqual(['abc' as never]);
});
});

View File

@@ -1,3 +1,17 @@
<!--
~ Copyright (C) 2023 StApps
~ This program is free software: you can redistribute it and/or modify it
~ under the terms of the GNU General Public License as published by the Free
~ Software Foundation, version 3.
~
~ This program is distributed in the hope that it will be useful, but WITHOUT
~ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
~ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
~ more details.
~
~ You should have received a copy of the GNU General Public License along with
~ this program. If not, see <https://www.gnu.org/licenses/>.
-->
<ion-accordion-group *ngIf="myCourses | async as myCourses" [value]="myCourses[0][0]">
<ion-accordion
*ngFor="let myCoursesDay of myCourses"

View File

@@ -14,11 +14,10 @@
*/
import {Component} from '@angular/core';
import {AuthHelperService} from '../../auth/auth-helper.service';
import {SCAuthorizationProviderType, SCUserConfiguration} from '@openstapps/core';
import {SCAuthorizationProviderType} from '@openstapps/core';
import {ActivatedRoute} from '@angular/router';
import {ScheduleProvider} from '../../calendar/schedule.provider';
import {profilePageSections} from '../../../../config/profile-page-sections';
import {filter, map} from 'rxjs/operators';
@Component({
selector: 'app-home',
@@ -26,19 +25,8 @@ import {filter, map} from 'rxjs/operators';
styleUrls: ['profile-page.scss'],
})
export class ProfilePageComponent {
user$ = this.authHelper.getProvider('default').user$.pipe(
filter(user => user !== undefined),
map(userInfo => {
return this.authHelper.getUserFromUserInfo(userInfo as object);
}),
);
sections = profilePageSections;
logins: SCAuthorizationProviderType[] = [];
userInfo?: SCUserConfiguration;
constructor(
readonly authHelper: AuthHelperService,
readonly activatedRoute: ActivatedRoute,
@@ -53,7 +41,6 @@ export class ProfilePageComponent {
async signOut(providerType: SCAuthorizationProviderType) {
await this.authHelper.getProvider(providerType).signOut();
this.userInfo = undefined;
}
ionViewWillEnter() {

View File

@@ -23,48 +23,7 @@
</ion-header>
<ion-content color="light" parallax [parallaxSize]="130">
<ion-card class="user-card">
<ion-card-header>
<ion-img src="assets/imgs/header.svg"></ion-img>
<span *ngIf="user$ | async as userInfo">
{{ userInfo.role ? (userInfo?.role | titlecase) : ('profile.role_guest' | translate | titlecase) }}
</span>
</ion-card-header>
<ion-card-content>
<ion-img class="profile-card-img" src="assets/imgs/profile-card-head.svg"></ion-img>
<ion-grid>
<ion-row>
<ion-col size="3"></ion-col>
<ion-col
*ngIf="authHelper.getProvider('default').isAuthenticated$ | async as loggedIn; else logInPrompt"
size="9"
class="main-info"
>
<ng-container *ngIf="user$ | async as userInfo">
<ion-text class="full-name"> {{ userInfo?.name }} </ion-text>
<div class="matriculation-number">
<ion-label> {{ 'profile.userInfo.studentId' | translate | uppercase }} </ion-label>
<ion-text> {{ userInfo?.studentId }} </ion-text>
</div>
<div class="user-name">
<ion-label> {{ 'profile.userInfo.username' | translate | uppercase }} </ion-label>
<ion-text>{{ userInfo?.id }}</ion-text>
</div>
<div class="email">
<ion-label> {{ 'profile.userInfo.email' | translate | uppercase }} </ion-label>
<ion-text> {{ userInfo?.email }} </ion-text>
</div>
</ng-container>
</ion-col>
<ng-template #logInPrompt>
<ion-col size="9">
<ion-text class="log-in-prompt"> {{ 'profile.userInfo.logInPrompt' | translate }} </ion-text>
</ion-col>
</ng-template>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>
<stapps-id-cards></stapps-id-cards>
<stapps-profile-page-section
*ngFor="let section of sections"
[item]="section"

View File

@@ -12,106 +12,3 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
// TODO: clean up this mess
.user-card {
position: relative;
max-width: 400px;
margin: var(--spacing-xl);
border-radius: var(--border-radius-default);
box-shadow: var(--shadow-profile-card);
ion-card-header {
--background: var(--ion-color-tertiary);
display: flex;
align-items: center;
padding-top: var(--spacing-sm);
padding-bottom: var(--spacing-sm);
ion-img {
display: block;
height: 36px;
margin-right: auto;
object-position: left 50%;
}
span {
padding-top: 3px;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
line-height: 1;
color: var(--ion-color-light);
}
}
ion-card-content {
min-height: 15vh;
.profile-card-img {
position: absolute;
width: 50%;
height: 100%;
margin-left: calc(var(--spacing-md) * -4);
opacity: 0.13;
object-position: left bottom;
}
.main-info {
display: grid;
grid-template-areas:
'fullName fullName'
'matriculationNumber userName'
'email email';
ion-label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
color: var(--ion-color-medium-shade);
}
ion-text {
display: block;
font-size: var(--font-size-md);
font-weight: var(--font-weight-bold);
color: var(--ion-color-text);
}
.full-name {
display: block;
grid-area: fullName;
margin-bottom: var(--spacing-sm);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
}
.matriculation-number {
grid-area: matriculationNumber;
margin-bottom: var(--spacing-sm);
}
.user-name {
grid-area: userName;
margin-bottom: var(--spacing-sm);
}
.email {
grid-area: email;
}
}
.log-in-prompt {
margin: auto 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semi-bold);
color: var(--ion-color-text);
}
}
}

View File

@@ -27,6 +27,7 @@ import {ThingTranslateModule} from '../../translation/thing-translate.module';
import {DataModule} from '../data/data.module';
import {MyCoursesComponent} from './page/my-courses.component';
import {MomentModule} from 'ngx-moment';
import {IdCardsComponent} from './id-cards.component';
const routes: Routes = [
{
@@ -49,6 +50,7 @@ const routes: Routes = [
ThingTranslateModule,
DataModule,
MomentModule,
IdCardsComponent,
],
})
export class ProfilePageModule {}

View File

@@ -0,0 +1,24 @@
import {Directive, ElementRef, HostListener} from '@angular/core';
@Directive({
selector: 'img[fullScreenImage]',
standalone: true,
})
export class FullScreenImageDirective {
constructor(private host: ElementRef) {}
@HostListener('click')
async onClick() {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await this.host.nativeElement.requestFullscreen();
if (
Math.sign(screen.width - screen.height) ===
Math.sign(this.host.nativeElement.width - this.host.nativeElement.height)
) {
await screen.orientation.lock('landscape');
}
}
}
}

View File

@@ -0,0 +1,41 @@
import {Pipe, PipeTransform} from '@angular/core';
import {SCRange, isInRange, SCISO8601DateRange} from '@openstapps/core';
import {merge, Observable, timer} from 'rxjs';
import {distinctUntilChanged, map, startWith} from 'rxjs/operators';
@Pipe({
name: 'isInRange',
pure: true,
standalone: true,
})
export class InRangePipe implements PipeTransform {
transform<T>(value: T, range: SCRange<T>): boolean {
return isInRange(value, range);
}
}
@Pipe({
name: 'toDateRange',
pure: true,
standalone: true,
})
export class ToDateRangePipe implements PipeTransform {
transform(value: SCISO8601DateRange): SCRange<Date> {
return Object.fromEntries(Object.entries(value).map(([key, value]) => [key, new Date(value)]));
}
}
@Pipe({
name: 'isInRangeNow',
pure: true,
standalone: true,
})
export class InRangeNowPipe implements PipeTransform {
transform(value: SCRange<Date>): Observable<boolean> {
return merge(timer(value.lte || value.lt || 0), timer(value.gte || value.gt || 0)).pipe(
startWith(0),
map(() => isInRange(new Date(), value)),
distinctUntilChanged(),
);
}
}

View File

@@ -14,7 +14,7 @@
-->
<ion-grid>
<ion-row>
<ion-col>
<ion-col *ngIf="title || item">
<a *ngIf="item; else titleTemplate" [routerLink]="['/data-detail', item.uid]" [state]="{item}">
<ng-container *ngTemplateOutlet="titleTemplate"></ng-container>
</a>
@@ -29,7 +29,7 @@
<ion-col size="auto" class="swiper-button">
<ion-button
fill="clear"
color="medium"
[color]="buttonColor"
(click)="swiper.scrollBy({left: -swiper.offsetWidth, behavior: 'smooth'})"
[disabled]="false"
>
@@ -39,7 +39,7 @@
<ion-col size="auto" class="swiper-button">
<ion-button
fill="clear"
color="medium"
[color]="buttonColor"
(click)="swiper.scrollBy({left: swiper.offsetWidth, behavior: 'smooth'})"
[disabled]="false"
>

View File

@@ -32,6 +32,8 @@ export class SectionComponent implements AfterContentInit {
@Input() item?: SCThings;
@Input() buttonColor = 'medium';
nativeElement = new ReplaySubject<HTMLElement>(1);
swiper = this.nativeElement.pipe(

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -512,6 +512,7 @@
}
},
"userInfo": {
"expired": "Abgelaufen",
"studentId": "Matrikelnr.",
"username": "Nutzername",
"email": "Email",

View File

@@ -512,6 +512,7 @@
}
},
"userInfo": {
"expired": "expired",
"studentId": "Matriculation Nr.",
"username": "Username",
"email": "Email",