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 { |
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; |
||||
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 { UserService } from '@ghostfolio/client/services/user/user.service'; |
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; |
import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; |
||||
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 { DeviceDetectorService } from 'ngx-device-detector'; |
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
import { StripeService } from 'ngx-stripe'; |
import { Subject, takeUntil } from 'rxjs'; |
||||
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'; |
|
||||
|
|
||||
@Component({ |
@Component({ |
||||
host: { class: 'page' }, |
host: { class: 'page has-tabs' }, |
||||
selector: 'gf-user-account-page', |
selector: 'gf-user-account-page', |
||||
styleUrls: ['./user-account-page.scss'], |
styleUrls: ['./user-account-page.scss'], |
||||
templateUrl: './user-account-page.html' |
templateUrl: './user-account-page.html' |
||||
}) |
}) |
||||
export class UserAccountPageComponent implements OnDestroy, OnInit { |
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 deviceType: string; |
||||
public hasPermissionForSubscription: boolean; |
public tabs: TabConfiguration[] = []; |
||||
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 user: User; |
public user: User; |
||||
|
|
||||
private unsubscribeSubject = new Subject<void>(); |
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
public constructor( |
public constructor( |
||||
private changeDetectorRef: ChangeDetectorRef, |
private changeDetectorRef: ChangeDetectorRef, |
||||
private dataService: DataService, |
|
||||
private deviceService: DeviceDetectorService, |
private deviceService: DeviceDetectorService, |
||||
private dialog: MatDialog, |
private userService: UserService |
||||
private snackBar: MatSnackBar, |
|
||||
private route: ActivatedRoute, |
|
||||
private router: Router, |
|
||||
private settingsStorageService: SettingsStorageService, |
|
||||
private stripeService: StripeService, |
|
||||
private userService: UserService, |
|
||||
public webAuthnService: WebAuthnService |
|
||||
) { |
) { |
||||
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 |
this.userService.stateChanged |
||||
.pipe(takeUntil(this.unsubscribeSubject)) |
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
.subscribe((state) => { |
.subscribe((state) => { |
||||
if (state?.user) { |
if (state?.user) { |
||||
this.user = state.user; |
this.user = state.user; |
||||
|
|
||||
this.defaultDateFormat = getDateFormatString( |
this.tabs = [ |
||||
this.user.settings.locale |
{ |
||||
); |
iconName: 'cog-outline', |
||||
|
label: $localize`Settings`, |
||||
this.hasPermissionToCreateAccess = hasPermission( |
path: ['/account'] |
||||
this.user.permissions, |
}, |
||||
permissions.createAccess |
{ |
||||
); |
iconName: 'diamond-outline', |
||||
|
label: $localize`Membership`, |
||||
this.hasPermissionToDeleteAccess = hasPermission( |
path: ['/account/membership'], |
||||
this.user.permissions, |
showCondition: !!this.user?.subscription |
||||
permissions.deleteAccess |
}, |
||||
); |
{ |
||||
|
iconName: 'share-social-outline', |
||||
this.hasPermissionToUpdateUserSettings = hasPermission( |
label: $localize`Access`, |
||||
this.user.permissions, |
path: ['/account', 'access'] |
||||
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.changeDetectorRef.markForCheck(); |
this.changeDetectorRef.markForCheck(); |
||||
} |
} |
||||
}); |
}); |
||||
|
|
||||
this.route.queryParams |
|
||||
.pipe(takeUntil(this.unsubscribeSubject)) |
|
||||
.subscribe((params) => { |
|
||||
if (params['createDialog']) { |
|
||||
this.openCreateAccessDialog(); |
|
||||
} |
|
||||
}); |
|
||||
} |
} |
||||
|
|
||||
public ngOnInit() { |
public ngOnInit() { |
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
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() { |
public ngOnDestroy() { |
||||
this.unsubscribeSubject.next(); |
this.unsubscribeSubject.next(); |
||||
this.unsubscribeSubject.complete(); |
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"> |
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto"> |
||||
<div class="row"> |
<router-outlet></router-outlet> |
||||
<div class="col"> |
</mat-tab-nav-panel> |
||||
<h2 class="h3 mb-3 text-center" i18n>Account</h2> |
|
||||
</div> |
<nav |
||||
</div> |
mat-align-tabs="center" |
||||
<div *ngIf="user?.settings" class="mb-5 row"> |
mat-tab-nav-bar |
||||
<div class="col"> |
[disablePagination]="true" |
||||
<mat-card appearance="outlined" class="mb-3"> |
[tabPanel]="tabPanel" |
||||
<mat-card-content> |
> |
||||
<div *ngIf="user?.subscription" class="d-flex py-1"> |
<ng-container *ngFor="let tab of tabs"> |
||||
<div class="pr-1 w-50" i18n>Membership</div> |
<a |
||||
<div class="pl-1 w-50"> |
#rla="routerLinkActive" |
||||
<div class="align-items-center d-flex mb-1"> |
*ngIf="tab.showCondition !== false" |
||||
<a [routerLink]="routerLinkPricing" |
class="px-3" |
||||
>{{ user?.subscription?.type }}</a |
mat-tab-link |
||||
> |
routerLinkActive |
||||
<gf-premium-indicator |
[active]="rla.isActive" |
||||
*ngIf="user?.subscription?.type === 'Premium'" |
[routerLink]="tab.path" |
||||
class="ml-1" |
[routerLinkActiveOptions]="{ exact: true }" |
||||
></gf-premium-indicator> |
> |
||||
</div> |
<ion-icon |
||||
<div *ngIf="user?.subscription?.type === 'Premium'"> |
[name]="tab.iconName" |
||||
<ng-container i18n>Valid until</ng-container> {{ |
[size]="deviceType === 'mobile' ? 'large': 'small'" |
||||
user?.subscription?.expiresAt | date: defaultDateFormat }} |
></ion-icon> |
||||
</div> |
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> |
||||
<div *ngIf="user?.subscription?.type === 'Basic'"> |
</a> |
||||
<ng-container |
</ng-container> |
||||
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings" |
</nav> |
||||
> |
|
||||
<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> |
|
||||
|
Loading…
Reference in new issue