mirror of https://github.com/ghostfolio/ghostfolio
87 changed files with 1850 additions and 807 deletions
@ -0,0 +1,46 @@ |
|||||
|
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; |
||||
|
import { Rule } from '@ghostfolio/api/models/rule'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { UserSettings } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
export class EmergencyFundSetup extends Rule<Settings> { |
||||
|
private emergencyFund: number; |
||||
|
|
||||
|
public constructor( |
||||
|
protected exchangeRateDataService: ExchangeRateDataService, |
||||
|
emergencyFund: number |
||||
|
) { |
||||
|
super(exchangeRateDataService, { |
||||
|
name: 'Emergency Fund: Set up' |
||||
|
}); |
||||
|
|
||||
|
this.emergencyFund = emergencyFund; |
||||
|
} |
||||
|
|
||||
|
public evaluate(ruleSettings: Settings) { |
||||
|
if (this.emergencyFund > ruleSettings.threshold) { |
||||
|
return { |
||||
|
evaluation: 'An emergency fund has been set up', |
||||
|
value: true |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
evaluation: 'No emergency fund has been set up', |
||||
|
value: false |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public getSettings(aUserSettings: UserSettings): Settings { |
||||
|
return { |
||||
|
baseCurrency: aUserSettings.baseCurrency, |
||||
|
isActive: true, |
||||
|
threshold: 0 |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
interface Settings extends RuleSettings { |
||||
|
baseCurrency: string; |
||||
|
threshold: number; |
||||
|
} |
@ -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)); |
||||
|
} |
@ -0,0 +1,31 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { Component } from '@angular/core'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { RouterModule } from '@angular/router'; |
||||
|
|
||||
|
import { products } from '../products'; |
||||
|
|
||||
|
@Component({ |
||||
|
host: { class: 'page' }, |
||||
|
imports: [CommonModule, MatButtonModule, RouterModule], |
||||
|
selector: 'gf-finary-page', |
||||
|
standalone: true, |
||||
|
styleUrls: ['../product-page-template.scss'], |
||||
|
templateUrl: '../product-page-template.html' |
||||
|
}) |
||||
|
export class FinaryPageComponent { |
||||
|
public product1 = products.find(({ key }) => { |
||||
|
return key === 'ghostfolio'; |
||||
|
}); |
||||
|
|
||||
|
public product2 = products.find(({ key }) => { |
||||
|
return key === 'finary'; |
||||
|
}); |
||||
|
|
||||
|
public routerLinkAbout = ['/' + $localize`about`]; |
||||
|
public routerLinkFeatures = ['/' + $localize`features`]; |
||||
|
public routerLinkResourcesPersonalFinanceTools = [ |
||||
|
'/' + $localize`resources`, |
||||
|
'personal-finance-tools' |
||||
|
]; |
||||
|
} |
@ -0,0 +1,31 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { Component } from '@angular/core'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { RouterModule } from '@angular/router'; |
||||
|
|
||||
|
import { products } from '../products'; |
||||
|
|
||||
|
@Component({ |
||||
|
host: { class: 'page' }, |
||||
|
imports: [CommonModule, MatButtonModule, RouterModule], |
||||
|
selector: 'gf-stockle-page', |
||||
|
standalone: true, |
||||
|
styleUrls: ['../product-page-template.scss'], |
||||
|
templateUrl: '../product-page-template.html' |
||||
|
}) |
||||
|
export class StocklePageComponent { |
||||
|
public product1 = products.find(({ key }) => { |
||||
|
return key === 'ghostfolio'; |
||||
|
}); |
||||
|
|
||||
|
public product2 = products.find(({ key }) => { |
||||
|
return key === 'stockle'; |
||||
|
}); |
||||
|
|
||||
|
public routerLinkAbout = ['/' + $localize`about`]; |
||||
|
public routerLinkFeatures = ['/' + $localize`features`]; |
||||
|
public routerLinkResourcesPersonalFinanceTools = [ |
||||
|
'/' + $localize`resources`, |
||||
|
'personal-finance-tools' |
||||
|
]; |
||||
|
} |
@ -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> |
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,226 @@ |
|||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: normal; |
||||
|
font-weight: 100; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-Thin.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-Thin.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: italic; |
||||
|
font-weight: 100; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-ThinItalic.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-ThinItalic.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: normal; |
||||
|
font-weight: 200; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-ExtraLight.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-ExtraLight.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: italic; |
||||
|
font-weight: 200; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-ExtraLightItalic.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-ExtraLightItalic.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: normal; |
||||
|
font-weight: 300; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-Light.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-Light.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: italic; |
||||
|
font-weight: 300; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-LightItalic.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-LightItalic.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: normal; |
||||
|
font-weight: 400; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-Regular.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-Regular.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: italic; |
||||
|
font-weight: 400; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-Italic.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-Italic.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: normal; |
||||
|
font-weight: 500; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-Medium.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-Medium.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: italic; |
||||
|
font-weight: 500; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-MediumItalic.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-MediumItalic.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: normal; |
||||
|
font-weight: 600; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-SemiBold.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-SemiBold.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: italic; |
||||
|
font-weight: 600; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-SemiBoldItalic.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-SemiBoldItalic.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: normal; |
||||
|
font-weight: 700; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-Bold.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-Bold.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: italic; |
||||
|
font-weight: 700; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-BoldItalic.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-BoldItalic.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: normal; |
||||
|
font-weight: 800; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-ExtraBold.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-ExtraBold.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: italic; |
||||
|
font-weight: 800; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-ExtraBoldItalic.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-ExtraBoldItalic.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: normal; |
||||
|
font-weight: 900; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-Black.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-Black.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
@font-face { |
||||
|
font-family: 'Inter'; |
||||
|
font-style: italic; |
||||
|
font-weight: 900; |
||||
|
font-display: swap; |
||||
|
src: |
||||
|
url('Inter-BlackItalic.woff2?v=3.19') format('woff2'), |
||||
|
url('Inter-BlackItalic.woff?v=3.19') format('woff'); |
||||
|
} |
||||
|
|
||||
|
/* ------------------------------------------------------- |
||||
|
Variable font. |
||||
|
Usage: |
||||
|
|
||||
|
html { font-family: 'Inter', sans-serif; } |
||||
|
@supports (font-variation-settings: normal) { |
||||
|
html { font-family: 'Inter var', sans-serif; } |
||||
|
} |
||||
|
*/ |
||||
|
@font-face { |
||||
|
font-family: 'Inter var'; |
||||
|
font-weight: 100 900; |
||||
|
font-display: swap; |
||||
|
font-style: normal; |
||||
|
font-named-instance: 'Regular'; |
||||
|
src: url('Inter-roman.var.woff2?v=3.19') format('woff2'); |
||||
|
} |
||||
|
@font-face { |
||||
|
font-family: 'Inter var'; |
||||
|
font-weight: 100 900; |
||||
|
font-display: swap; |
||||
|
font-style: italic; |
||||
|
font-named-instance: 'Italic'; |
||||
|
src: url('Inter-italic.var.woff2?v=3.19') format('woff2'); |
||||
|
} |
||||
|
|
||||
|
/* -------------------------------------------------------------------------- |
||||
|
[EXPERIMENTAL] Multi-axis, single variable font. |
||||
|
|
||||
|
Slant axis is not yet widely supported (as of February 2019) and thus this |
||||
|
multi-axis single variable font is opt-in rather than the default. |
||||
|
|
||||
|
When using this, you will probably need to set font-variation-settings |
||||
|
explicitly, e.g. |
||||
|
|
||||
|
* { font-variation-settings: "slnt" 0deg } |
||||
|
.italic { font-variation-settings: "slnt" 10deg } |
||||
|
|
||||
|
*/ |
||||
|
@font-face { |
||||
|
font-family: 'Inter var experimental'; |
||||
|
font-weight: 100 900; |
||||
|
font-display: swap; |
||||
|
font-style: oblique 0deg 10deg; |
||||
|
src: url('Inter.var.woff2?v=3.19') format('woff2'); |
||||
|
} |
Loading…
Reference in new issue