mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
* Create components for access, membership and settings * Add tabs * Update changelogpull/2404/head
Thomas Kaul
1 year ago
committed by
GitHub
23 changed files with 1040 additions and 753 deletions
@ -0,0 +1,146 @@ |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
OnDestroy, |
|||
OnInit |
|||
} from '@angular/core'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { ActivatedRoute, Router } from '@angular/router'; |
|||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; |
|||
import { DataService } from '@ghostfolio/client/services/data.service'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { Access, User } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { DeviceDetectorService } from 'ngx-device-detector'; |
|||
import { Subject } from 'rxjs'; |
|||
import { takeUntil } from 'rxjs/operators'; |
|||
|
|||
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
selector: 'gf-user-account-access', |
|||
styleUrls: ['./user-account-access.scss'], |
|||
templateUrl: './user-account-access.html' |
|||
}) |
|||
export class UserAccountAccessComponent implements OnDestroy, OnInit { |
|||
public accesses: Access[]; |
|||
public deviceType: string; |
|||
public hasPermissionToCreateAccess: boolean; |
|||
public hasPermissionToDeleteAccess: boolean; |
|||
public user: User; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
private dataService: DataService, |
|||
private deviceService: DeviceDetectorService, |
|||
private dialog: MatDialog, |
|||
private route: ActivatedRoute, |
|||
private router: Router, |
|||
private userService: UserService |
|||
) { |
|||
const { globalPermissions } = this.dataService.fetchInfo(); |
|||
|
|||
this.hasPermissionToDeleteAccess = hasPermission( |
|||
globalPermissions, |
|||
permissions.deleteAccess |
|||
); |
|||
|
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
if (state?.user) { |
|||
this.user = state.user; |
|||
|
|||
this.hasPermissionToCreateAccess = hasPermission( |
|||
this.user.permissions, |
|||
permissions.createAccess |
|||
); |
|||
|
|||
this.hasPermissionToDeleteAccess = hasPermission( |
|||
this.user.permissions, |
|||
permissions.deleteAccess |
|||
); |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
|
|||
this.route.queryParams |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((params) => { |
|||
if (params['createDialog']) { |
|||
this.openCreateAccessDialog(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
|||
|
|||
this.update(); |
|||
} |
|||
|
|||
public onDeleteAccess(aId: string) { |
|||
this.dataService |
|||
.deleteAccess(aId) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: () => { |
|||
this.update(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private openCreateAccessDialog(): void { |
|||
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, { |
|||
data: { |
|||
access: { |
|||
alias: '', |
|||
type: 'PUBLIC' |
|||
} |
|||
}, |
|||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', |
|||
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
|||
}); |
|||
|
|||
dialogRef |
|||
.afterClosed() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((data: any) => { |
|||
const access: CreateAccessDto = data?.access; |
|||
|
|||
if (access) { |
|||
this.dataService |
|||
.postAccess({ alias: access.alias }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: () => { |
|||
this.update(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
this.router.navigate(['.'], { relativeTo: this.route }); |
|||
}); |
|||
} |
|||
|
|||
private update() { |
|||
this.dataService |
|||
.fetchAccesses() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((accesses) => { |
|||
this.accesses = accesses; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,17 @@ |
|||
<div class="container"> |
|||
<h1 |
|||
class="align-items-center d-none d-sm-flex h3 justify-content-center mb-3 text-center" |
|||
> |
|||
<span i18n>Granted Access</span> |
|||
<gf-premium-indicator |
|||
*ngIf="user?.subscription?.type === 'Basic'" |
|||
class="ml-1" |
|||
></gf-premium-indicator> |
|||
</h1> |
|||
<gf-access-table |
|||
[accesses]="accesses" |
|||
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess" |
|||
[showActions]="hasPermissionToDeleteAccess" |
|||
(accessDeleted)="onDeleteAccess($event)" |
|||
></gf-access-table> |
|||
</div> |
@ -0,0 +1,23 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { NgModule } from '@angular/core'; |
|||
import { MatDialogModule } from '@angular/material/dialog'; |
|||
import { RouterModule } from '@angular/router'; |
|||
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; |
|||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; |
|||
|
|||
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module'; |
|||
import { UserAccountAccessComponent } from './user-account-access.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [UserAccountAccessComponent], |
|||
exports: [UserAccountAccessComponent], |
|||
imports: [ |
|||
CommonModule, |
|||
GfCreateOrUpdateAccessDialogModule, |
|||
GfPortfolioAccessTableModule, |
|||
GfPremiumIndicatorModule, |
|||
MatDialogModule, |
|||
RouterModule |
|||
] |
|||
}) |
|||
export class GfUserAccountAccessModule {} |
@ -0,0 +1,12 @@ |
|||
:host { |
|||
color: rgb(var(--dark-primary-text)); |
|||
display: block; |
|||
|
|||
gf-access-table { |
|||
overflow-x: auto; |
|||
} |
|||
} |
|||
|
|||
:host-context(.is-dark-theme) { |
|||
color: rgb(var(--light-primary-text)); |
|||
} |
@ -0,0 +1,160 @@ |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
OnDestroy, |
|||
OnInit |
|||
} from '@angular/core'; |
|||
import { |
|||
MatSnackBar, |
|||
MatSnackBarRef, |
|||
TextOnlySnackBar |
|||
} from '@angular/material/snack-bar'; |
|||
import { DataService } from '@ghostfolio/client/services/data.service'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { getDateFormatString } from '@ghostfolio/common/helper'; |
|||
import { User } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { StripeService } from 'ngx-stripe'; |
|||
import { EMPTY, Subject } from 'rxjs'; |
|||
import { catchError, switchMap, takeUntil } from 'rxjs/operators'; |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
selector: 'gf-user-account-membership', |
|||
styleUrls: ['./user-account-membership.scss'], |
|||
templateUrl: './user-account-membership.html' |
|||
}) |
|||
export class UserAccountMembershipComponent implements OnDestroy, OnInit { |
|||
public baseCurrency: string; |
|||
public coupon: number; |
|||
public couponId: string; |
|||
public defaultDateFormat: string; |
|||
public hasPermissionForSubscription: boolean; |
|||
public hasPermissionToUpdateUserSettings: boolean; |
|||
public price: number; |
|||
public priceId: string; |
|||
public routerLinkPricing = ['/' + $localize`pricing`]; |
|||
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>; |
|||
public trySubscriptionMail = |
|||
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards'; |
|||
public user: User; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
private dataService: DataService, |
|||
private snackBar: MatSnackBar, |
|||
private stripeService: StripeService, |
|||
private userService: UserService |
|||
) { |
|||
const { baseCurrency, globalPermissions, subscriptions } = |
|||
this.dataService.fetchInfo(); |
|||
|
|||
this.baseCurrency = baseCurrency; |
|||
|
|||
this.hasPermissionForSubscription = hasPermission( |
|||
globalPermissions, |
|||
permissions.enableSubscription |
|||
); |
|||
|
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
if (state?.user) { |
|||
this.user = state.user; |
|||
|
|||
this.defaultDateFormat = getDateFormatString( |
|||
this.user.settings.locale |
|||
); |
|||
|
|||
this.hasPermissionToUpdateUserSettings = hasPermission( |
|||
this.user.permissions, |
|||
permissions.updateUserSettings |
|||
); |
|||
|
|||
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon; |
|||
this.couponId = |
|||
subscriptions?.[this.user.subscription.offer]?.couponId; |
|||
this.price = subscriptions?.[this.user.subscription.offer]?.price; |
|||
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnInit() {} |
|||
|
|||
public onCheckout() { |
|||
this.dataService |
|||
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) |
|||
.pipe( |
|||
switchMap(({ sessionId }: { sessionId: string }) => { |
|||
return this.stripeService.redirectToCheckout({ sessionId }); |
|||
}), |
|||
catchError((error) => { |
|||
alert(error.message); |
|||
throw error; |
|||
}) |
|||
) |
|||
.subscribe((result) => { |
|||
if (result.error) { |
|||
alert(result.error.message); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public onRedeemCoupon() { |
|||
let couponCode = prompt($localize`Please enter your coupon code:`); |
|||
couponCode = couponCode?.trim(); |
|||
|
|||
if (couponCode) { |
|||
this.dataService |
|||
.redeemCoupon(couponCode) |
|||
.pipe( |
|||
takeUntil(this.unsubscribeSubject), |
|||
catchError(() => { |
|||
this.snackBar.open( |
|||
'😞 ' + $localize`Could not redeem coupon code`, |
|||
undefined, |
|||
{ |
|||
duration: 3000 |
|||
} |
|||
); |
|||
|
|||
return EMPTY; |
|||
}) |
|||
) |
|||
.subscribe(() => { |
|||
this.snackBarRef = this.snackBar.open( |
|||
'✅ ' + $localize`Coupon code has been redeemed`, |
|||
$localize`Reload`, |
|||
{ |
|||
duration: 3000 |
|||
} |
|||
); |
|||
|
|||
this.snackBarRef |
|||
.afterDismissed() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
window.location.reload(); |
|||
}); |
|||
|
|||
this.snackBarRef |
|||
.onAction() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
window.location.reload(); |
|||
}); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
} |
@ -0,0 +1,69 @@ |
|||
<div class="container"> |
|||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1> |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<div class="d-flex"> |
|||
<div class="mx-auto"> |
|||
<div class="align-items-center d-flex mb-1"> |
|||
<a [routerLink]="routerLinkPricing" |
|||
>{{ user?.subscription?.type }}</a |
|||
> |
|||
<gf-premium-indicator |
|||
*ngIf="user?.subscription?.type === 'Premium'" |
|||
class="ml-1" |
|||
></gf-premium-indicator> |
|||
</div> |
|||
<div *ngIf="user?.subscription?.type === 'Premium'"> |
|||
<ng-container i18n>Valid until</ng-container> {{ |
|||
user?.subscription?.expiresAt | date: defaultDateFormat }} |
|||
</div> |
|||
<div *ngIf="user?.subscription?.type === 'Basic'"> |
|||
<ng-container |
|||
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings" |
|||
> |
|||
<button color="primary" mat-flat-button (click)="onCheckout()"> |
|||
<ng-container *ngIf="user.subscription.offer === 'default'" i18n |
|||
>Upgrade</ng-container |
|||
> |
|||
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n |
|||
>Renew</ng-container |
|||
> |
|||
</button> |
|||
<div *ngIf="price" class="mt-1"> |
|||
<ng-container *ngIf="coupon" |
|||
><del class="text-muted" |
|||
>{{ baseCurrency }} {{ price }}</del |
|||
> {{ baseCurrency }} {{ price - coupon |
|||
}}</ng-container |
|||
> |
|||
<ng-container *ngIf="!coupon" |
|||
>{{ baseCurrency }} {{ price }}</ng-container |
|||
> <span i18n>per year</span> |
|||
</div> |
|||
</ng-container> |
|||
<a |
|||
*ngIf="!user?.subscription?.expiresAt" |
|||
class="mr-2 my-2" |
|||
mat-stroked-button |
|||
[href]="trySubscriptionMail" |
|||
><span i18n>Try Premium</span> |
|||
<gf-premium-indicator |
|||
class="d-inline-block ml-1" |
|||
[enableLink]="false" |
|||
></gf-premium-indicator |
|||
></a> |
|||
<a |
|||
*ngIf="hasPermissionToUpdateUserSettings" |
|||
class="mr-2 my-2" |
|||
i18n |
|||
mat-stroked-button |
|||
[routerLink]="" |
|||
(click)="onRedeemCoupon()" |
|||
>Redeem Coupon</a |
|||
> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
@ -0,0 +1,23 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { NgModule } from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { MatCardModule } from '@angular/material/card'; |
|||
import { RouterModule } from '@angular/router'; |
|||
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; |
|||
import { GfValueModule } from '@ghostfolio/ui/value'; |
|||
|
|||
import { UserAccountMembershipComponent } from './user-account-membership.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [UserAccountMembershipComponent], |
|||
exports: [UserAccountMembershipComponent], |
|||
imports: [ |
|||
CommonModule, |
|||
GfPremiumIndicatorModule, |
|||
GfValueModule, |
|||
MatButtonModule, |
|||
MatCardModule, |
|||
RouterModule |
|||
] |
|||
}) |
|||
export class GfUserAccountMembershipModule {} |
@ -0,0 +1,8 @@ |
|||
:host { |
|||
color: rgb(var(--dark-primary-text)); |
|||
display: block; |
|||
} |
|||
|
|||
:host-context(.is-dark-theme) { |
|||
color: rgb(var(--light-primary-text)); |
|||
} |
@ -0,0 +1,258 @@ |
|||
import { |
|||
ChangeDetectionStrategy, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
OnDestroy, |
|||
OnInit, |
|||
ViewChild |
|||
} from '@angular/core'; |
|||
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox'; |
|||
import { DataService } from '@ghostfolio/client/services/data.service'; |
|||
import { |
|||
STAY_SIGNED_IN, |
|||
SettingsStorageService |
|||
} from '@ghostfolio/client/services/settings-storage.service'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; |
|||
import { downloadAsFile } from '@ghostfolio/common/helper'; |
|||
import { User } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { format, parseISO } from 'date-fns'; |
|||
import { uniq } from 'lodash'; |
|||
import { EMPTY, Subject } from 'rxjs'; |
|||
import { catchError, takeUntil } from 'rxjs/operators'; |
|||
|
|||
@Component({ |
|||
changeDetection: ChangeDetectionStrategy.OnPush, |
|||
selector: 'gf-user-account-settings', |
|||
styleUrls: ['./user-account-settings.scss'], |
|||
templateUrl: './user-account-settings.html' |
|||
}) |
|||
export class UserAccountSettingsComponent implements OnDestroy, OnInit { |
|||
@ViewChild('toggleSignInWithFingerprintEnabledElement') |
|||
signInWithFingerprintElement: MatCheckbox; |
|||
|
|||
public appearancePlaceholder = $localize`Auto`; |
|||
public baseCurrency: string; |
|||
public currencies: string[] = []; |
|||
public hasPermissionToUpdateViewMode: boolean; |
|||
public hasPermissionToUpdateUserSettings: boolean; |
|||
public language = document.documentElement.lang; |
|||
public locales = [ |
|||
'de', |
|||
'de-CH', |
|||
'en-GB', |
|||
'en-US', |
|||
'es', |
|||
'fr', |
|||
'it', |
|||
'nl', |
|||
'pt', |
|||
'tr' |
|||
]; |
|||
public user: User; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
private dataService: DataService, |
|||
private settingsStorageService: SettingsStorageService, |
|||
private userService: UserService, |
|||
public webAuthnService: WebAuthnService |
|||
) { |
|||
const { baseCurrency, currencies } = this.dataService.fetchInfo(); |
|||
|
|||
this.baseCurrency = baseCurrency; |
|||
this.currencies = currencies; |
|||
|
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
if (state?.user) { |
|||
this.user = state.user; |
|||
|
|||
this.hasPermissionToUpdateUserSettings = hasPermission( |
|||
this.user.permissions, |
|||
permissions.updateUserSettings |
|||
); |
|||
|
|||
this.hasPermissionToUpdateViewMode = hasPermission( |
|||
this.user.permissions, |
|||
permissions.updateViewMode |
|||
); |
|||
|
|||
this.locales.push(this.user.settings.locale); |
|||
this.locales = uniq(this.locales.sort()); |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.update(); |
|||
} |
|||
|
|||
public onChangeUserSetting(aKey: string, aValue: string) { |
|||
this.dataService |
|||
.putUserSetting({ [aKey]: aValue }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
|
|||
if (aKey === 'language') { |
|||
if (aValue) { |
|||
window.location.href = `../${aValue}/account`; |
|||
} else { |
|||
window.location.href = `../`; |
|||
} |
|||
} |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) { |
|||
this.dataService |
|||
.putUserSetting({ isExperimentalFeatures: aEvent.checked }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onExport() { |
|||
this.dataService |
|||
.fetchExport() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((data) => { |
|||
for (const activity of data.activities) { |
|||
delete activity.id; |
|||
} |
|||
|
|||
downloadAsFile({ |
|||
content: data, |
|||
fileName: `ghostfolio-export-${format( |
|||
parseISO(data.meta.date), |
|||
'yyyyMMddHHmm' |
|||
)}.json`,
|
|||
format: 'json' |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onRestrictedViewChange(aEvent: MatCheckboxChange) { |
|||
this.dataService |
|||
.putUserSetting({ isRestrictedView: aEvent.checked }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) { |
|||
if (aEvent.checked) { |
|||
this.registerDevice(); |
|||
} else { |
|||
const confirmation = confirm( |
|||
$localize`Do you really want to remove this sign in method?` |
|||
); |
|||
|
|||
if (confirmation) { |
|||
this.deregisterDevice(); |
|||
} else { |
|||
this.update(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public onViewModeChange(aEvent: MatCheckboxChange) { |
|||
this.dataService |
|||
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private deregisterDevice() { |
|||
this.webAuthnService |
|||
.deregister() |
|||
.pipe( |
|||
takeUntil(this.unsubscribeSubject), |
|||
catchError(() => { |
|||
this.update(); |
|||
|
|||
return EMPTY; |
|||
}) |
|||
) |
|||
.subscribe(() => { |
|||
this.update(); |
|||
}); |
|||
} |
|||
|
|||
private registerDevice() { |
|||
this.webAuthnService |
|||
.register() |
|||
.pipe( |
|||
takeUntil(this.unsubscribeSubject), |
|||
catchError(() => { |
|||
this.update(); |
|||
|
|||
return EMPTY; |
|||
}) |
|||
) |
|||
.subscribe(() => { |
|||
this.settingsStorageService.removeSetting(STAY_SIGNED_IN); |
|||
|
|||
this.update(); |
|||
}); |
|||
} |
|||
|
|||
private update() { |
|||
if (this.signInWithFingerprintElement) { |
|||
this.signInWithFingerprintElement.checked = |
|||
this.webAuthnService.isEnabled() ?? false; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,197 @@ |
|||
<div class="container"> |
|||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Settings</h1> |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<div class="align-items-center d-flex py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Presenter View</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Protection for sensitive information like absolute performances and |
|||
quantity values |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.isRestrictedView" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onRestrictedViewChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="d-flex mt-4 py-1"> |
|||
<form #changeUserSettingsForm="ngForm" class="w-100"> |
|||
<div class="d-flex mb-2"> |
|||
<div class="align-items-center d-flex pt-1 pt-1 w-50"> |
|||
<ng-container i18n>Base Currency</ng-container> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field appearance="outline" class="w-100 without-hint"> |
|||
<mat-select |
|||
name="baseCurrency" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="user.settings.baseCurrency" |
|||
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)" |
|||
> |
|||
<mat-option |
|||
*ngFor="let currency of currencies" |
|||
[value]="currency" |
|||
>{{ currency }}</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mb-2"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Language</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field appearance="outline" class="w-100 without-hint"> |
|||
<mat-select |
|||
name="language" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="language" |
|||
(selectionChange)="onChangeUserSetting('language', $event.value)" |
|||
> |
|||
<mat-option [value]="null"></mat-option> |
|||
<mat-option value="de">Deutsch</mat-option> |
|||
<mat-option value="en">English</mat-option> |
|||
<mat-option value="es" |
|||
>Español (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="fr" |
|||
>Français (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="it" |
|||
>Italiano (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="nl" |
|||
>Nederlands (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="pt" |
|||
>Português (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="tr" |
|||
>Türkçe (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mb-2"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Locale</div> |
|||
<div class="hint-text text-muted"> |
|||
<ng-container i18n>Date and number format</ng-container> |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field appearance="outline" class="w-100 without-hint"> |
|||
<mat-select |
|||
name="locale" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="user.settings.locale" |
|||
(selectionChange)="onChangeUserSetting('locale', $event.value)" |
|||
> |
|||
<mat-option [value]="null"></mat-option> |
|||
<mat-option *ngFor="let locale of locales" [value]="locale" |
|||
>{{ locale }}</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="d-flex"> |
|||
<div class="align-items-center d-flex pr-1 pt-1 w-50"> |
|||
<ng-container i18n>Appearance</ng-container> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field appearance="outline" class="w-100 without-hint"> |
|||
<mat-select |
|||
class="with-placeholder-as-option" |
|||
name="colorScheme" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[placeholder]="appearancePlaceholder" |
|||
[value]="user?.settings?.colorScheme" |
|||
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)" |
|||
> |
|||
<mat-option i18n [value]="null">Auto</mat-option> |
|||
<mat-option i18n value="LIGHT">Light</mat-option> |
|||
<mat-option i18n value="DARK">Dark</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<div class="d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Zen Mode</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Distraction-free experience for turbulent times |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.viewMode === 'ZEN'" |
|||
[disabled]="!hasPermissionToUpdateViewMode" |
|||
(change)="onViewModeChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Biometric Authentication</div> |
|||
<div class="hint-text text-muted" i18n>Sign in with fingerprint</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
#toggleSignInWithFingerprintEnabledElement |
|||
color="primary" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onSignInWithFingerprintChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div |
|||
*ngIf="hasPermissionToUpdateUserSettings" |
|||
class="align-items-center d-flex mt-4 py-1" |
|||
> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Experimental Features</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Sneak peek at upcoming functionality |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.isExperimentalFeatures" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onExperimentalFeaturesChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50" i18n>User ID</div> |
|||
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div> |
|||
</div> |
|||
<div class="align-items-center d-flex py-1"> |
|||
<div class="pr-1 w-50"></div> |
|||
<div class="pl-1 text-monospace w-50"> |
|||
<button color="primary" mat-flat-button (click)="onExport()"> |
|||
<span i18n>Export Data</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
@ -0,0 +1,30 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { NgModule } from '@angular/core'; |
|||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { MatCardModule } from '@angular/material/card'; |
|||
import { MatCheckboxModule } from '@angular/material/checkbox'; |
|||
import { MatFormFieldModule } from '@angular/material/form-field'; |
|||
import { MatSelectModule } from '@angular/material/select'; |
|||
import { RouterModule } from '@angular/router'; |
|||
import { GfValueModule } from '@ghostfolio/ui/value'; |
|||
|
|||
import { UserAccountSettingsComponent } from './user-account-settings.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [UserAccountSettingsComponent], |
|||
exports: [UserAccountSettingsComponent], |
|||
imports: [ |
|||
CommonModule, |
|||
FormsModule, |
|||
GfValueModule, |
|||
MatButtonModule, |
|||
MatCardModule, |
|||
MatCheckboxModule, |
|||
MatFormFieldModule, |
|||
MatSelectModule, |
|||
ReactiveFormsModule, |
|||
RouterModule |
|||
] |
|||
}) |
|||
export class GfUserAccountSettingsModule {} |
@ -0,0 +1,13 @@ |
|||
:host { |
|||
color: rgb(var(--dark-primary-text)); |
|||
display: block; |
|||
|
|||
.hint-text { |
|||
font-size: 90%; |
|||
line-height: 1.2; |
|||
} |
|||
} |
|||
|
|||
:host-context(.is-dark-theme) { |
|||
color: rgb(var(--light-primary-text)); |
|||
} |
@ -1,448 +1,63 @@ |
|||
import { |
|||
ChangeDetectorRef, |
|||
Component, |
|||
OnDestroy, |
|||
OnInit, |
|||
ViewChild |
|||
} from '@angular/core'; |
|||
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { |
|||
MatSnackBar, |
|||
MatSnackBarRef, |
|||
TextOnlySnackBar |
|||
} from '@angular/material/snack-bar'; |
|||
import { ActivatedRoute, Router } from '@angular/router'; |
|||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; |
|||
import { DataService } from '@ghostfolio/client/services/data.service'; |
|||
import { |
|||
STAY_SIGNED_IN, |
|||
SettingsStorageService |
|||
} from '@ghostfolio/client/services/settings-storage.service'; |
|||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; |
|||
import { downloadAsFile, getDateFormatString } from '@ghostfolio/common/helper'; |
|||
import { Access, User } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { format, parseISO } from 'date-fns'; |
|||
import { uniq } from 'lodash'; |
|||
import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; |
|||
import { DeviceDetectorService } from 'ngx-device-detector'; |
|||
import { StripeService } from 'ngx-stripe'; |
|||
import { EMPTY, Subject } from 'rxjs'; |
|||
import { catchError, switchMap, takeUntil } from 'rxjs/operators'; |
|||
|
|||
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; |
|||
import { Subject, takeUntil } from 'rxjs'; |
|||
|
|||
@Component({ |
|||
host: { class: 'page' }, |
|||
host: { class: 'page has-tabs' }, |
|||
selector: 'gf-user-account-page', |
|||
styleUrls: ['./user-account-page.scss'], |
|||
templateUrl: './user-account-page.html' |
|||
}) |
|||
export class UserAccountPageComponent implements OnDestroy, OnInit { |
|||
@ViewChild('toggleSignInWithFingerprintEnabledElement') |
|||
signInWithFingerprintElement: MatCheckbox; |
|||
|
|||
public accesses: Access[]; |
|||
public appearancePlaceholder = $localize`Auto`; |
|||
public baseCurrency: string; |
|||
public coupon: number; |
|||
public couponId: string; |
|||
public currencies: string[] = []; |
|||
public defaultDateFormat: string; |
|||
public deviceType: string; |
|||
public hasPermissionForSubscription: boolean; |
|||
public hasPermissionToCreateAccess: boolean; |
|||
public hasPermissionToDeleteAccess: boolean; |
|||
public hasPermissionToUpdateViewMode: boolean; |
|||
public hasPermissionToUpdateUserSettings: boolean; |
|||
public language = document.documentElement.lang; |
|||
public locales = [ |
|||
'de', |
|||
'de-CH', |
|||
'en-GB', |
|||
'en-US', |
|||
'es', |
|||
'fr', |
|||
'it', |
|||
'nl', |
|||
'pt', |
|||
'tr' |
|||
]; |
|||
public price: number; |
|||
public priceId: string; |
|||
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>; |
|||
public trySubscriptionMail = |
|||
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards'; |
|||
public tabs: TabConfiguration[] = []; |
|||
public user: User; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
private dataService: DataService, |
|||
private deviceService: DeviceDetectorService, |
|||
private dialog: MatDialog, |
|||
private snackBar: MatSnackBar, |
|||
private route: ActivatedRoute, |
|||
private router: Router, |
|||
private settingsStorageService: SettingsStorageService, |
|||
private stripeService: StripeService, |
|||
private userService: UserService, |
|||
public webAuthnService: WebAuthnService |
|||
private userService: UserService |
|||
) { |
|||
const { baseCurrency, currencies, globalPermissions, subscriptions } = |
|||
this.dataService.fetchInfo(); |
|||
|
|||
this.baseCurrency = baseCurrency; |
|||
this.currencies = currencies; |
|||
|
|||
this.hasPermissionForSubscription = hasPermission( |
|||
globalPermissions, |
|||
permissions.enableSubscription |
|||
); |
|||
|
|||
this.hasPermissionToDeleteAccess = hasPermission( |
|||
globalPermissions, |
|||
permissions.deleteAccess |
|||
); |
|||
|
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
if (state?.user) { |
|||
this.user = state.user; |
|||
|
|||
this.defaultDateFormat = getDateFormatString( |
|||
this.user.settings.locale |
|||
); |
|||
|
|||
this.hasPermissionToCreateAccess = hasPermission( |
|||
this.user.permissions, |
|||
permissions.createAccess |
|||
); |
|||
|
|||
this.hasPermissionToDeleteAccess = hasPermission( |
|||
this.user.permissions, |
|||
permissions.deleteAccess |
|||
); |
|||
|
|||
this.hasPermissionToUpdateUserSettings = hasPermission( |
|||
this.user.permissions, |
|||
permissions.updateUserSettings |
|||
); |
|||
|
|||
this.hasPermissionToUpdateViewMode = hasPermission( |
|||
this.user.permissions, |
|||
permissions.updateViewMode |
|||
); |
|||
|
|||
this.locales.push(this.user.settings.locale); |
|||
this.locales = uniq(this.locales.sort()); |
|||
|
|||
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon; |
|||
this.couponId = |
|||
subscriptions?.[this.user.subscription.offer]?.couponId; |
|||
this.price = subscriptions?.[this.user.subscription.offer]?.price; |
|||
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; |
|||
this.tabs = [ |
|||
{ |
|||
iconName: 'cog-outline', |
|||
label: $localize`Settings`, |
|||
path: ['/account'] |
|||
}, |
|||
{ |
|||
iconName: 'diamond-outline', |
|||
label: $localize`Membership`, |
|||
path: ['/account/membership'], |
|||
showCondition: !!this.user?.subscription |
|||
}, |
|||
{ |
|||
iconName: 'share-social-outline', |
|||
label: $localize`Access`, |
|||
path: ['/account', 'access'] |
|||
} |
|||
]; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
|
|||
this.route.queryParams |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((params) => { |
|||
if (params['createDialog']) { |
|||
this.openCreateAccessDialog(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
|||
|
|||
this.update(); |
|||
} |
|||
|
|||
public onChangeUserSetting(aKey: string, aValue: string) { |
|||
this.dataService |
|||
.putUserSetting({ [aKey]: aValue }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
|
|||
if (aKey === 'language') { |
|||
if (aValue) { |
|||
window.location.href = `../${aValue}/account`; |
|||
} else { |
|||
window.location.href = `../`; |
|||
} |
|||
} |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onCheckout() { |
|||
this.dataService |
|||
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) |
|||
.pipe( |
|||
switchMap(({ sessionId }: { sessionId: string }) => { |
|||
return this.stripeService.redirectToCheckout({ sessionId }); |
|||
}), |
|||
catchError((error) => { |
|||
alert(error.message); |
|||
throw error; |
|||
}) |
|||
) |
|||
.subscribe((result) => { |
|||
if (result.error) { |
|||
alert(result.error.message); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public onDeleteAccess(aId: string) { |
|||
this.dataService |
|||
.deleteAccess(aId) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: () => { |
|||
this.update(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) { |
|||
this.dataService |
|||
.putUserSetting({ isExperimentalFeatures: aEvent.checked }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onExport() { |
|||
this.dataService |
|||
.fetchExport() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((data) => { |
|||
for (const activity of data.activities) { |
|||
delete activity.id; |
|||
} |
|||
|
|||
downloadAsFile({ |
|||
content: data, |
|||
fileName: `ghostfolio-export-${format( |
|||
parseISO(data.meta.date), |
|||
'yyyyMMddHHmm' |
|||
)}.json`,
|
|||
format: 'json' |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onRedeemCoupon() { |
|||
let couponCode = prompt($localize`Please enter your coupon code:`); |
|||
couponCode = couponCode?.trim(); |
|||
|
|||
if (couponCode) { |
|||
this.dataService |
|||
.redeemCoupon(couponCode) |
|||
.pipe( |
|||
takeUntil(this.unsubscribeSubject), |
|||
catchError(() => { |
|||
this.snackBar.open( |
|||
'😞 ' + $localize`Could not redeem coupon code`, |
|||
undefined, |
|||
{ |
|||
duration: 3000 |
|||
} |
|||
); |
|||
|
|||
return EMPTY; |
|||
}) |
|||
) |
|||
.subscribe(() => { |
|||
this.snackBarRef = this.snackBar.open( |
|||
'✅ ' + $localize`Coupon code has been redeemed`, |
|||
$localize`Reload`, |
|||
{ |
|||
duration: 3000 |
|||
} |
|||
); |
|||
|
|||
this.snackBarRef |
|||
.afterDismissed() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
window.location.reload(); |
|||
}); |
|||
|
|||
this.snackBarRef |
|||
.onAction() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
window.location.reload(); |
|||
}); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
public onRestrictedViewChange(aEvent: MatCheckboxChange) { |
|||
this.dataService |
|||
.putUserSetting({ isRestrictedView: aEvent.checked }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) { |
|||
if (aEvent.checked) { |
|||
this.registerDevice(); |
|||
} else { |
|||
const confirmation = confirm( |
|||
$localize`Do you really want to remove this sign in method?` |
|||
); |
|||
|
|||
if (confirmation) { |
|||
this.deregisterDevice(); |
|||
} else { |
|||
this.update(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public onViewModeChange(aEvent: MatCheckboxChange) { |
|||
this.dataService |
|||
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private openCreateAccessDialog(): void { |
|||
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, { |
|||
data: { |
|||
access: { |
|||
alias: '', |
|||
type: 'PUBLIC' |
|||
} |
|||
}, |
|||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', |
|||
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
|||
}); |
|||
|
|||
dialogRef |
|||
.afterClosed() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((data: any) => { |
|||
const access: CreateAccessDto = data?.access; |
|||
|
|||
if (access) { |
|||
this.dataService |
|||
.postAccess({ alias: access.alias }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: () => { |
|||
this.update(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
this.router.navigate(['.'], { relativeTo: this.route }); |
|||
}); |
|||
} |
|||
|
|||
private deregisterDevice() { |
|||
this.webAuthnService |
|||
.deregister() |
|||
.pipe( |
|||
takeUntil(this.unsubscribeSubject), |
|||
catchError(() => { |
|||
this.update(); |
|||
|
|||
return EMPTY; |
|||
}) |
|||
) |
|||
.subscribe(() => { |
|||
this.update(); |
|||
}); |
|||
} |
|||
|
|||
private registerDevice() { |
|||
this.webAuthnService |
|||
.register() |
|||
.pipe( |
|||
takeUntil(this.unsubscribeSubject), |
|||
catchError(() => { |
|||
this.update(); |
|||
|
|||
return EMPTY; |
|||
}) |
|||
) |
|||
.subscribe(() => { |
|||
this.settingsStorageService.removeSetting(STAY_SIGNED_IN); |
|||
|
|||
this.update(); |
|||
}); |
|||
} |
|||
|
|||
private update() { |
|||
this.dataService |
|||
.fetchAccesses() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((response) => { |
|||
this.accesses = response; |
|||
|
|||
if (this.signInWithFingerprintElement) { |
|||
this.signInWithFingerprintElement.checked = |
|||
this.webAuthnService.isEnabled() ?? false; |
|||
} |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
} |
|||
} |
|||
|
@ -1,309 +1,29 @@ |
|||
<div class="container"> |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<h2 class="h3 mb-3 text-center" i18n>Account</h2> |
|||
</div> |
|||
</div> |
|||
<div *ngIf="user?.settings" class="mb-5 row"> |
|||
<div class="col"> |
|||
<mat-card appearance="outlined" class="mb-3"> |
|||
<mat-card-content> |
|||
<div *ngIf="user?.subscription" class="d-flex py-1"> |
|||
<div class="pr-1 w-50" i18n>Membership</div> |
|||
<div class="pl-1 w-50"> |
|||
<div class="align-items-center d-flex mb-1"> |
|||
<a [routerLink]="routerLinkPricing" |
|||
>{{ user?.subscription?.type }}</a |
|||
> |
|||
<gf-premium-indicator |
|||
*ngIf="user?.subscription?.type === 'Premium'" |
|||
class="ml-1" |
|||
></gf-premium-indicator> |
|||
</div> |
|||
<div *ngIf="user?.subscription?.type === 'Premium'"> |
|||
<ng-container i18n>Valid until</ng-container> {{ |
|||
user?.subscription?.expiresAt | date: defaultDateFormat }} |
|||
</div> |
|||
<div *ngIf="user?.subscription?.type === 'Basic'"> |
|||
<ng-container |
|||
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings" |
|||
> |
|||
<button |
|||
color="primary" |
|||
mat-flat-button |
|||
(click)="onCheckout()" |
|||
> |
|||
<ng-container |
|||
*ngIf="user.subscription.offer === 'default'" |
|||
i18n |
|||
>Upgrade</ng-container |
|||
> |
|||
<ng-container |
|||
*ngIf="user.subscription.offer === 'renewal'" |
|||
i18n |
|||
>Renew</ng-container |
|||
> |
|||
</button> |
|||
<div *ngIf="price" class="mt-1"> |
|||
<ng-container *ngIf="coupon" |
|||
><del class="text-muted" |
|||
>{{ baseCurrency }} {{ price }}</del |
|||
> {{ baseCurrency }} {{ price - coupon |
|||
}}</ng-container |
|||
> |
|||
<ng-container *ngIf="!coupon" |
|||
>{{ baseCurrency }} {{ price }}</ng-container |
|||
> <span i18n>per year</span> |
|||
</div> |
|||
</ng-container> |
|||
<a |
|||
*ngIf="!user?.subscription?.expiresAt" |
|||
class="mr-2 my-2" |
|||
mat-stroked-button |
|||
[href]="trySubscriptionMail" |
|||
><span i18n>Try Premium</span> |
|||
<gf-premium-indicator |
|||
class="d-inline-block ml-1" |
|||
[enableLink]="false" |
|||
></gf-premium-indicator |
|||
></a> |
|||
<a |
|||
*ngIf="hasPermissionToUpdateUserSettings" |
|||
class="mr-2 my-2" |
|||
i18n |
|||
mat-stroked-button |
|||
[routerLink]="" |
|||
(click)="onRedeemCoupon()" |
|||
>Redeem Coupon</a |
|||
> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Presenter View</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Protection for sensitive information like absolute performances |
|||
and quantity values |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.isRestrictedView" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onRestrictedViewChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="d-flex mt-4 py-1"> |
|||
<form #changeUserSettingsForm="ngForm" class="w-100"> |
|||
<div class="d-flex mb-2"> |
|||
<div class="align-items-center d-flex pt-1 pt-1 w-50"> |
|||
<ng-container i18n>Base Currency</ng-container> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field |
|||
appearance="outline" |
|||
class="w-100 without-hint" |
|||
> |
|||
<mat-select |
|||
name="baseCurrency" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="user.settings.baseCurrency" |
|||
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)" |
|||
> |
|||
<mat-option |
|||
*ngFor="let currency of currencies" |
|||
[value]="currency" |
|||
>{{ currency }}</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mb-2"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Language</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field |
|||
appearance="outline" |
|||
class="w-100 without-hint" |
|||
> |
|||
<mat-select |
|||
name="language" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="language" |
|||
(selectionChange)="onChangeUserSetting('language', $event.value)" |
|||
> |
|||
<mat-option [value]="null"></mat-option> |
|||
<mat-option value="de">Deutsch</mat-option> |
|||
<mat-option value="en">English</mat-option> |
|||
<mat-option value="es" |
|||
>Español (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="fr" |
|||
>Français (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="it" |
|||
>Italiano (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="nl" |
|||
>Nederlands (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="pt" |
|||
>Português (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="tr" |
|||
>Türkçe (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mb-2"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Locale</div> |
|||
<div class="hint-text text-muted"> |
|||
<ng-container i18n>Date and number format</ng-container> |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field |
|||
appearance="outline" |
|||
class="w-100 without-hint" |
|||
> |
|||
<mat-select |
|||
name="locale" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="user.settings.locale" |
|||
(selectionChange)="onChangeUserSetting('locale', $event.value)" |
|||
> |
|||
<mat-option [value]="null"></mat-option> |
|||
<mat-option |
|||
*ngFor="let locale of locales" |
|||
[value]="locale" |
|||
>{{ locale }}</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="d-flex"> |
|||
<div class="align-items-center d-flex pr-1 pt-1 w-50"> |
|||
<ng-container i18n>Appearance</ng-container> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field |
|||
appearance="outline" |
|||
class="w-100 without-hint" |
|||
> |
|||
<mat-select |
|||
class="with-placeholder-as-option" |
|||
name="colorScheme" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[placeholder]="appearancePlaceholder" |
|||
[value]="user?.settings?.colorScheme" |
|||
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)" |
|||
> |
|||
<mat-option i18n [value]="null">Auto</mat-option> |
|||
<mat-option i18n value="LIGHT">Light</mat-option> |
|||
<mat-option i18n value="DARK">Dark</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<div class="d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Zen Mode</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Distraction-free experience for turbulent times |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.viewMode === 'ZEN'" |
|||
[disabled]="!hasPermissionToUpdateViewMode" |
|||
(change)="onViewModeChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Biometric Authentication</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Sign in with fingerprint |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
#toggleSignInWithFingerprintEnabledElement |
|||
color="primary" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onSignInWithFingerprintChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div |
|||
*ngIf="hasPermissionToUpdateUserSettings" |
|||
class="align-items-center d-flex mt-4 py-1" |
|||
> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Experimental Features</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Sneak peek at upcoming functionality |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.isExperimentalFeatures" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onExperimentalFeaturesChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50" i18n>User ID</div> |
|||
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div> |
|||
</div> |
|||
<div class="align-items-center d-flex py-1"> |
|||
<div class="pr-1 w-50"></div> |
|||
<div class="pl-1 text-monospace w-50"> |
|||
<button color="primary" mat-flat-button (click)="onExport()"> |
|||
<span i18n>Export Data</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
</div> |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<h2 class="align-items-center d-flex h3 justify-content-center mb-3"> |
|||
<span i18n>Granted Access</span> |
|||
<gf-premium-indicator |
|||
*ngIf="user?.subscription?.type === 'Basic'" |
|||
class="ml-1" |
|||
></gf-premium-indicator> |
|||
</h2> |
|||
<gf-access-table |
|||
[accesses]="accesses" |
|||
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess" |
|||
[showActions]="hasPermissionToDeleteAccess" |
|||
(accessDeleted)="onDeleteAccess($event)" |
|||
></gf-access-table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto"> |
|||
<router-outlet></router-outlet> |
|||
</mat-tab-nav-panel> |
|||
|
|||
<nav |
|||
mat-align-tabs="center" |
|||
mat-tab-nav-bar |
|||
[disablePagination]="true" |
|||
[tabPanel]="tabPanel" |
|||
> |
|||
<ng-container *ngFor="let tab of tabs"> |
|||
<a |
|||
#rla="routerLinkActive" |
|||
*ngIf="tab.showCondition !== false" |
|||
class="px-3" |
|||
mat-tab-link |
|||
routerLinkActive |
|||
[active]="rla.isActive" |
|||
[routerLink]="tab.path" |
|||
[routerLinkActiveOptions]="{ exact: true }" |
|||
> |
|||
<ion-icon |
|||
[name]="tab.iconName" |
|||
[size]="deviceType === 'mobile' ? 'large': 'small'" |
|||
></ion-icon> |
|||
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> |
|||
</a> |
|||
</ng-container> |
|||
</nav> |
|||
|
Loading…
Reference in new issue