diff --git a/CHANGELOG.md b/CHANGELOG.md index d90589acc..b0457ebb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a new static portfolio analysis rule: Emergency fund setup +- Added tabs to the user account page ### Changed diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.component.ts rename to apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.html rename to apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.module.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.module.ts similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.module.ts rename to apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.module.ts diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.scss b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.scss similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/create-or-update-access-dialog.scss rename to apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.scss diff --git a/apps/client/src/app/pages/user-account/create-or-update-access-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts similarity index 100% rename from apps/client/src/app/pages/user-account/create-or-update-access-dialog/interfaces/interfaces.ts rename to apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts new file mode 100644 index 000000000..1bd1d85d6 --- /dev/null +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -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(); + + 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(); + }); + } +} diff --git a/apps/client/src/app/components/user-account-access/user-account-access.html b/apps/client/src/app/components/user-account-access/user-account-access.html new file mode 100644 index 000000000..c3aa485cd --- /dev/null +++ b/apps/client/src/app/components/user-account-access/user-account-access.html @@ -0,0 +1,17 @@ +
+

+ Granted Access + +

+ +
diff --git a/apps/client/src/app/components/user-account-access/user-account-access.module.ts b/apps/client/src/app/components/user-account-access/user-account-access.module.ts new file mode 100644 index 000000000..76495db63 --- /dev/null +++ b/apps/client/src/app/components/user-account-access/user-account-access.module.ts @@ -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 {} diff --git a/apps/client/src/app/components/user-account-access/user-account-access.scss b/apps/client/src/app/components/user-account-access/user-account-access.scss new file mode 100644 index 000000000..695f786f2 --- /dev/null +++ b/apps/client/src/app/components/user-account-access/user-account-access.scss @@ -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)); +} diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts new file mode 100644 index 000000000..13d7495a9 --- /dev/null +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts @@ -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; + 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(); + + 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(); + } +} diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.html b/apps/client/src/app/components/user-account-membership/user-account-membership.html new file mode 100644 index 000000000..1681e3e16 --- /dev/null +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.html @@ -0,0 +1,69 @@ +
+

Membership

+
+
+
+
+ +
+ Valid until {{ + user?.subscription?.expiresAt | date: defaultDateFormat }} +
+
+ + +
+ {{ baseCurrency }} {{ price }} {{ baseCurrency }} {{ price - coupon + }} + {{ baseCurrency }} {{ price }} per year +
+
+ Try Premium + + Redeem Coupon +
+
+
+
+
+
diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.module.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.module.ts new file mode 100644 index 000000000..bef027c62 --- /dev/null +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.module.ts @@ -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 {} diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.scss b/apps/client/src/app/components/user-account-membership/user-account-membership.scss new file mode 100644 index 000000000..39eb6792e --- /dev/null +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.scss @@ -0,0 +1,8 @@ +:host { + color: rgb(var(--dark-primary-text)); + display: block; +} + +:host-context(.is-dark-theme) { + color: rgb(var(--light-primary-text)); +} diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts new file mode 100644 index 000000000..a52812ed3 --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -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(); + + 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; + } + } +} diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html new file mode 100644 index 000000000..12f3da458 --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -0,0 +1,197 @@ +
+

Settings

+
+
+
+
+
Presenter View
+
+ Protection for sensitive information like absolute performances and + quantity values +
+
+
+ +
+
+
+
+
+
+ Base Currency +
+
+ + + {{ currency }} + + +
+
+
+
+
Language
+
+
+ + + + Deutsch + English + Español (Community) + Français (Community) + Italiano (Community) + Nederlands (Community) + Português (Community) + Türkçe (Community) + + +
+
+
+
+
Locale
+
+ Date and number format +
+
+
+ + + + {{ locale }} + + +
+
+
+
+ Appearance +
+
+ + + Auto + Light + Dark + + +
+
+
+
+
+
+
Zen Mode
+
+ Distraction-free experience for turbulent times +
+
+
+ +
+
+
+
+
Biometric Authentication
+
Sign in with fingerprint
+
+
+ +
+
+
+
+
Experimental Features
+
+ Sneak peek at upcoming functionality +
+
+
+ +
+
+
+
User ID
+
{{ user?.id }}
+
+
+
+
+ +
+
+
+
+
diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts new file mode 100644 index 000000000..24e57ff20 --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.module.ts @@ -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 {} diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.scss b/apps/client/src/app/components/user-account-settings/user-account-settings.scss new file mode 100644 index 000000000..1bcd1c65a --- /dev/null +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.scss @@ -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)); +} diff --git a/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts b/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts index f52591d21..568095009 100644 --- a/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts +++ b/apps/client/src/app/pages/user-account/user-account-page-routing.module.ts @@ -1,5 +1,8 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { UserAccountAccessComponent } from '@ghostfolio/client/components/user-account-access/user-account-access.component'; +import { UserAccountMembershipComponent } from '@ghostfolio/client/components/user-account-membership/user-account-membership.component'; +import { UserAccountSettingsComponent } from '@ghostfolio/client/components/user-account-settings/user-account-settings.component'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { UserAccountPageComponent } from './user-account-page.component'; @@ -7,6 +10,23 @@ import { UserAccountPageComponent } from './user-account-page.component'; const routes: Routes = [ { canActivate: [AuthGuard], + children: [ + { + path: '', + component: UserAccountSettingsComponent, + title: $localize`Settings` + }, + { + path: 'membership', + component: UserAccountMembershipComponent, + title: $localize`Membership` + }, + { + path: 'access', + component: UserAccountAccessComponent, + title: $localize`Access` + } + ], component: UserAccountPageComponent, path: '', title: $localize`My Ghostfolio` diff --git a/apps/client/src/app/pages/user-account/user-account-page.component.ts b/apps/client/src/app/pages/user-account/user-account-page.component.ts index c02c8bdf1..970dadd6a 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.component.ts +++ b/apps/client/src/app/pages/user-account/user-account-page.component.ts @@ -1,448 +1,63 @@ -import { - ChangeDetectorRef, - Component, - OnDestroy, - OnInit, - ViewChild -} from '@angular/core'; -import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox'; -import { MatDialog } from '@angular/material/dialog'; -import { - MatSnackBar, - MatSnackBarRef, - TextOnlySnackBar -} from '@angular/material/snack-bar'; -import { ActivatedRoute, Router } from '@angular/router'; -import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; -import { DataService } from '@ghostfolio/client/services/data.service'; -import { - STAY_SIGNED_IN, - SettingsStorageService -} from '@ghostfolio/client/services/settings-storage.service'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; -import { downloadAsFile, getDateFormatString } from '@ghostfolio/common/helper'; -import { Access, User } from '@ghostfolio/common/interfaces'; -import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { format, parseISO } from 'date-fns'; -import { uniq } from 'lodash'; +import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { StripeService } from 'ngx-stripe'; -import { EMPTY, Subject } from 'rxjs'; -import { catchError, switchMap, takeUntil } from 'rxjs/operators'; - -import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; +import { Subject, takeUntil } from 'rxjs'; @Component({ - host: { class: 'page' }, + host: { class: 'page has-tabs' }, selector: 'gf-user-account-page', styleUrls: ['./user-account-page.scss'], templateUrl: './user-account-page.html' }) export class UserAccountPageComponent implements OnDestroy, OnInit { - @ViewChild('toggleSignInWithFingerprintEnabledElement') - signInWithFingerprintElement: MatCheckbox; - - public accesses: Access[]; - public appearancePlaceholder = $localize`Auto`; - public baseCurrency: string; - public coupon: number; - public couponId: string; - public currencies: string[] = []; - public defaultDateFormat: string; public deviceType: string; - public hasPermissionForSubscription: boolean; - public hasPermissionToCreateAccess: boolean; - public hasPermissionToDeleteAccess: boolean; - public hasPermissionToUpdateViewMode: boolean; - public hasPermissionToUpdateUserSettings: boolean; - public language = document.documentElement.lang; - public locales = [ - 'de', - 'de-CH', - 'en-GB', - 'en-US', - 'es', - 'fr', - 'it', - 'nl', - 'pt', - 'tr' - ]; - public price: number; - public priceId: string; - public snackBarRef: MatSnackBarRef; - public trySubscriptionMail = - 'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards'; + public tabs: TabConfiguration[] = []; public user: User; private unsubscribeSubject = new Subject(); public constructor( private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, private deviceService: DeviceDetectorService, - private dialog: MatDialog, - private snackBar: MatSnackBar, - private route: ActivatedRoute, - private router: Router, - private settingsStorageService: SettingsStorageService, - private stripeService: StripeService, - private userService: UserService, - public webAuthnService: WebAuthnService + private userService: UserService ) { - const { baseCurrency, currencies, globalPermissions, subscriptions } = - this.dataService.fetchInfo(); - - this.baseCurrency = baseCurrency; - this.currencies = currencies; - - this.hasPermissionForSubscription = hasPermission( - globalPermissions, - permissions.enableSubscription - ); - - this.hasPermissionToDeleteAccess = hasPermission( - globalPermissions, - permissions.deleteAccess - ); - this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { if (state?.user) { this.user = state.user; - this.defaultDateFormat = getDateFormatString( - this.user.settings.locale - ); - - this.hasPermissionToCreateAccess = hasPermission( - this.user.permissions, - permissions.createAccess - ); - - this.hasPermissionToDeleteAccess = hasPermission( - this.user.permissions, - permissions.deleteAccess - ); - - this.hasPermissionToUpdateUserSettings = hasPermission( - this.user.permissions, - permissions.updateUserSettings - ); - - this.hasPermissionToUpdateViewMode = hasPermission( - this.user.permissions, - permissions.updateViewMode - ); - - this.locales.push(this.user.settings.locale); - this.locales = uniq(this.locales.sort()); - - this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon; - this.couponId = - subscriptions?.[this.user.subscription.offer]?.couponId; - this.price = subscriptions?.[this.user.subscription.offer]?.price; - this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; + this.tabs = [ + { + iconName: 'cog-outline', + label: $localize`Settings`, + path: ['/account'] + }, + { + iconName: 'diamond-outline', + label: $localize`Membership`, + path: ['/account/membership'], + showCondition: !!this.user?.subscription + }, + { + iconName: 'share-social-outline', + label: $localize`Access`, + path: ['/account', 'access'] + } + ]; this.changeDetectorRef.markForCheck(); } }); - - this.route.queryParams - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((params) => { - if (params['createDialog']) { - this.openCreateAccessDialog(); - } - }); } public ngOnInit() { this.deviceType = this.deviceService.getDeviceInfo().deviceType; - - this.update(); - } - - public onChangeUserSetting(aKey: string, aValue: string) { - this.dataService - .putUserSetting({ [aKey]: aValue }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - - if (aKey === 'language') { - if (aValue) { - window.location.href = `../${aValue}/account`; - } else { - window.location.href = `../`; - } - } - }); - }); - } - - public onCheckout() { - this.dataService - .createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) - .pipe( - switchMap(({ sessionId }: { sessionId: string }) => { - return this.stripeService.redirectToCheckout({ sessionId }); - }), - catchError((error) => { - alert(error.message); - throw error; - }) - ) - .subscribe((result) => { - if (result.error) { - alert(result.error.message); - } - }); - } - - public onDeleteAccess(aId: string) { - this.dataService - .deleteAccess(aId) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe({ - next: () => { - this.update(); - } - }); - } - - public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) { - this.dataService - .putUserSetting({ isExperimentalFeatures: aEvent.checked }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); - } - - public onExport() { - this.dataService - .fetchExport() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((data) => { - for (const activity of data.activities) { - delete activity.id; - } - - downloadAsFile({ - content: data, - fileName: `ghostfolio-export-${format( - parseISO(data.meta.date), - 'yyyyMMddHHmm' - )}.json`, - format: 'json' - }); - }); - } - - public onRedeemCoupon() { - let couponCode = prompt($localize`Please enter your coupon code:`); - couponCode = couponCode?.trim(); - - if (couponCode) { - this.dataService - .redeemCoupon(couponCode) - .pipe( - takeUntil(this.unsubscribeSubject), - catchError(() => { - this.snackBar.open( - '😞 ' + $localize`Could not redeem coupon code`, - undefined, - { - duration: 3000 - } - ); - - return EMPTY; - }) - ) - .subscribe(() => { - this.snackBarRef = this.snackBar.open( - '✅ ' + $localize`Coupon code has been redeemed`, - $localize`Reload`, - { - duration: 3000 - } - ); - - this.snackBarRef - .afterDismissed() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - window.location.reload(); - }); - - this.snackBarRef - .onAction() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - window.location.reload(); - }); - }); - } - } - - public onRestrictedViewChange(aEvent: MatCheckboxChange) { - this.dataService - .putUserSetting({ isRestrictedView: aEvent.checked }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); - } - - public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) { - if (aEvent.checked) { - this.registerDevice(); - } else { - const confirmation = confirm( - $localize`Do you really want to remove this sign in method?` - ); - - if (confirmation) { - this.deregisterDevice(); - } else { - this.update(); - } - } - } - - public onViewModeChange(aEvent: MatCheckboxChange) { - this.dataService - .putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(() => { - this.userService.remove(); - - this.userService - .get() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((user) => { - this.user = user; - - this.changeDetectorRef.markForCheck(); - }); - }); } public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } - - private openCreateAccessDialog(): void { - const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, { - data: { - access: { - alias: '', - type: 'PUBLIC' - } - }, - height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef - .afterClosed() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((data: any) => { - const access: CreateAccessDto = data?.access; - - if (access) { - this.dataService - .postAccess({ alias: access.alias }) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe({ - next: () => { - this.update(); - } - }); - } - - this.router.navigate(['.'], { relativeTo: this.route }); - }); - } - - private deregisterDevice() { - this.webAuthnService - .deregister() - .pipe( - takeUntil(this.unsubscribeSubject), - catchError(() => { - this.update(); - - return EMPTY; - }) - ) - .subscribe(() => { - this.update(); - }); - } - - private registerDevice() { - this.webAuthnService - .register() - .pipe( - takeUntil(this.unsubscribeSubject), - catchError(() => { - this.update(); - - return EMPTY; - }) - ) - .subscribe(() => { - this.settingsStorageService.removeSetting(STAY_SIGNED_IN); - - this.update(); - }); - } - - private update() { - this.dataService - .fetchAccesses() - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((response) => { - this.accesses = response; - - if (this.signInWithFingerprintElement) { - this.signInWithFingerprintElement.checked = - this.webAuthnService.isEnabled() ?? false; - } - - this.changeDetectorRef.markForCheck(); - }); - } } diff --git a/apps/client/src/app/pages/user-account/user-account-page.html b/apps/client/src/app/pages/user-account/user-account-page.html index debd190c1..d3fbca534 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.html +++ b/apps/client/src/app/pages/user-account/user-account-page.html @@ -1,309 +1,29 @@ -
-
-
-

Account

-
-
-
-
- - -
-
Membership
-
- -
- Valid until {{ - user?.subscription?.expiresAt | date: defaultDateFormat }} -
-
- - -
- {{ baseCurrency }} {{ price }} {{ baseCurrency }} {{ price - coupon - }} - {{ baseCurrency }} {{ price }} per year -
-
- Try Premium - - Redeem Coupon -
-
-
-
-
-
Presenter View
-
- Protection for sensitive information like absolute performances - and quantity values -
-
-
- -
-
-
-
-
-
- Base Currency -
-
- - - {{ currency }} - - -
-
-
-
-
Language
-
-
- - - - Deutsch - English - Español (Community) - Français (Community) - Italiano (Community) - Nederlands (Community) - Português (Community) - Türkçe (Community) - - -
-
-
-
-
Locale
-
- Date and number format -
-
-
- - - - {{ locale }} - - -
-
-
-
- Appearance -
-
- - - Auto - Light - Dark - - -
-
-
-
-
-
-
Zen Mode
-
- Distraction-free experience for turbulent times -
-
-
- -
-
-
-
-
Biometric Authentication
-
- Sign in with fingerprint -
-
-
- -
-
-
-
-
Experimental Features
-
- Sneak peek at upcoming functionality -
-
-
- -
-
-
-
User ID
-
{{ user?.id }}
-
-
-
-
- -
-
-
-
-
-
-
-
-

- Granted Access - -

- -
-
-
+ + + + + diff --git a/apps/client/src/app/pages/user-account/user-account-page.module.ts b/apps/client/src/app/pages/user-account/user-account-page.module.ts index 240441ada..3c6670af4 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.module.ts +++ b/apps/client/src/app/pages/user-account/user-account-page.module.ts @@ -1,18 +1,10 @@ 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 { MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { RouterModule } from '@angular/router'; -import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; -import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; -import { GfValueModule } from '@ghostfolio/ui/value'; +import { MatTabsModule } from '@angular/material/tabs'; +import { GfUserAccountAccessModule } from '@ghostfolio/client/components/user-account-access/user-account-access.module'; +import { GfUserAccountMembershipModule } from '@ghostfolio/client/components/user-account-membership/user-account-membership.module'; +import { GfUserAccountSettingsModule } from '@ghostfolio/client/components/user-account-settings/user-account-settings.module'; -import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module'; import { UserAccountPageRoutingModule } from './user-account-page-routing.module'; import { UserAccountPageComponent } from './user-account-page.component'; @@ -20,19 +12,10 @@ import { UserAccountPageComponent } from './user-account-page.component'; declarations: [UserAccountPageComponent], imports: [ CommonModule, - FormsModule, - GfCreateOrUpdateAccessDialogModule, - GfPortfolioAccessTableModule, - GfPremiumIndicatorModule, - GfValueModule, - MatButtonModule, - MatCardModule, - MatCheckboxModule, - MatDialogModule, - MatFormFieldModule, - MatSelectModule, - ReactiveFormsModule, - RouterModule, + GfUserAccountAccessModule, + GfUserAccountMembershipModule, + GfUserAccountSettingsModule, + MatTabsModule, UserAccountPageRoutingModule ] }) diff --git a/apps/client/src/app/pages/user-account/user-account-page.scss b/apps/client/src/app/pages/user-account/user-account-page.scss index 6dddf0e35..6a0b74854 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.scss +++ b/apps/client/src/app/pages/user-account/user-account-page.scss @@ -1,15 +1,7 @@ +@import 'apps/client/src/styles/ghostfolio-style'; + :host { color: rgb(var(--dark-primary-text)); - display: block; - - gf-access-table { - overflow-x: auto; - } - - .hint-text { - font-size: 90%; - line-height: 1.2; - } } :host-context(.is-dark-theme) {