Compare commits

...

12 Commits

Author SHA1 Message Date
646dcb91c5 Release 1.8.0 (#122) 2021-05-24 16:32:11 +02:00
ad961f3039 Bugfix/fix missing header of public pages (#121)
* Fix missing header of public pages

* Update changelog
2021-05-24 16:28:42 +02:00
c16f743b07 Feature/add tools section (#120)
* Add tools section

* Update changelog
2021-05-24 16:25:59 +02:00
8e13f6ef9b Bugfix/fix performance chart (#119)
* Fix value of performance chart

* Update changelog
2021-05-24 16:24:54 +02:00
95bcdea69b Refactor cd to changeDetectorRef (#118) 2021-05-24 10:12:53 +02:00
0d6fe4a232 Feature/refactor user service as observable store (#117)
* Implement user service as observable store

* Clean up tokenStorageService usage

* Update changelog
2021-05-24 09:38:44 +02:00
ced4519412 Reorder (#116) 2021-05-22 16:14:16 +02:00
ef8b7718b1 Release 1.7.0 (#115) 2021-05-22 13:49:25 +02:00
b4762dc463 Bugfix/fix internal navigation with query param (#114)
* Fix internal navigation with query parameter

* Add guard

* Update changelog
2021-05-22 13:48:06 +02:00
9851cce382 Feature/hide footer on mobile (#113)
* Hide footer on mobile

* Improve about text

* Update changelog
2021-05-22 13:45:50 +02:00
a1460a98fd Release 1.6.0 (#112) 2021-05-22 10:22:46 +02:00
1a553a296f Feature/improve user table of admin control panel (#109)
* Improve user table

* Add index
* Increase limit
* Improve alignment of cell content

* Update changelog
2021-05-22 10:17:12 +02:00
42 changed files with 459 additions and 244 deletions

2
.env
View File

@ -5,9 +5,9 @@ REDIS_HOST=localhost
REDIS_PORT=6379
# POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=ghostfolio-db
ACCESS_TOKEN_SALT=GHOSTFOLIO
ALPHA_VANTAGE_API_KEY=

View File

@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.8.0 - 24.05.2021
### Added
- Added a section for _Analysis_, _X-ray_ and upcoming tools
### Changed
- Introduced a user service implemented as an observable store (single source of truth for state)
### Fixed
- Fixed the performance chart by considering the investment
- Fixed missing header of public pages (_About_, _Pricing_, _Resources_)
## 1.7.0 - 22.05.2021
### Changed
- Hid footer on mobile (except on landing page)
### Fixed
- Fixed the internal navigation of the _Zen Mode_ in combination with a query parameter
## 1.6.0 - 22.05.2021
### Added
- Added an index in the user table of the admin control panel
### Changed
- Improved the alignment in the user table of the admin control panel
## 1.5.0 - 22.05.2021
### Added

View File

@ -108,7 +108,7 @@ export class AdminService {
createdAt: true,
id: true
},
take: 20,
take: 30,
where: {
NOT: {
Analytics: null

View File

@ -158,8 +158,8 @@ export class PortfolioService {
return {
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
grossPerformancePercent: portfolioItem.grossPerformancePercent,
marketPrice: portfolioItem.value || null,
value: portfolioItem.value || null
marketPrice: portfolioItem.value ?? null,
value: portfolioItem.value - portfolioItem.investment ?? null
};
});
}

View File

@ -71,6 +71,11 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/login/login-page.module').then((m) => m.LoginPageModule)
},
{
path: 'tools',
loadChildren: () =>
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
},
{
path: 'transactions',
loadChildren: () =>

View File

@ -4,6 +4,7 @@
[currentRoute]="currentRoute"
[info]="info"
[user]="user"
(signOut)="onSignOut()"
></gf-header>
</header>
@ -25,7 +26,10 @@
<router-outlet></router-outlet>
</main>
<footer class="footer d-flex justify-content-center position-absolute w-100">
<footer
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
class="footer d-flex justify-content-center position-absolute w-100"
>
<div class="container text-center">
<div>
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>

View File

@ -10,12 +10,14 @@ import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { MaterialCssVarsService } from 'angular-material-css-vars';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { environment } from '../environments/environment';
import { DataService } from './services/data.service';
import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';
@Component({
selector: 'gf-root',
@ -27,25 +29,29 @@ export class AppComponent implements OnDestroy, OnInit {
public canCreateAccount: boolean;
public currentRoute: string;
public currentYear = new Date().getFullYear();
public deviceType: string;
public info: InfoItem;
public isLoggedIn = false;
public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>();
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private materialCssVarsService: MaterialCssVarsService,
private router: Router,
private tokenStorageService: TokenStorageService
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
this.initializeTheme();
this.user = undefined;
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService.fetchInfo().subscribe((info) => {
this.info = info;
});
@ -59,26 +65,22 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentRoute = urlSegments[0].path;
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.isLoggedIn = !!this.tokenStorageService.getToken();
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
if (this.isLoggedIn) {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.canCreateAccount = hasPermission(
this.user.permissions,
permissions.createUserAccount
);
this.cd.markForCheck();
});
} else {
this.canCreateAccount = hasPermission(
this.user.permissions,
permissions.createUserAccount
);
} else if (!this.tokenStorageService.getToken()) {
// User has not been logged in
this.user = null;
}
this.changeDetectorRef.markForCheck();
});
}
@ -87,6 +89,13 @@ export class AppComponent implements OnDestroy, OnInit {
window.location.reload();
}
public onSignOut() {
this.tokenStorageService.signOut();
this.userService.remove();
window.location.reload();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -19,18 +19,15 @@
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'analysis' ? 'primary' : null"
[routerLink]="['/analysis']"
>Analysis</a
>
<a
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'report' ? 'primary' : null"
[routerLink]="['/report']"
>X-ray</a
[color]="
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
? 'primary'
: null
"
[routerLink]="['/tools']"
>Tools</a
>
<a
class="d-none d-sm-block mx-1"
@ -142,17 +139,14 @@
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'analysis' }"
[routerLink]="['/analysis']"
>Analysis</a
>
<a
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'report' }"
[routerLink]="['/report']"
>X-ray</a
[ngClass]="{
'font-weight-bold':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
}"
[routerLink]="['/tools']"
>Tools</a
>
<a
class="d-block d-sm-none"

View File

@ -1,8 +1,10 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges
OnChanges,
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
@ -26,6 +28,8 @@ export class HeaderComponent implements OnChanges {
@Input() info: InfoItem;
@Input() user: User;
@Output() signOut = new EventEmitter<void>();
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
@ -75,8 +79,7 @@ export class HeaderComponent implements OnChanges {
}
public onSignOut() {
this.tokenStorageService.signOut();
window.location.reload();
this.signOut.next();
}
public openLoginDialog(): void {

View File

@ -28,7 +28,7 @@ export class PerformanceChartDialog {
public title: string;
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<PerformanceChartDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
@ -46,7 +46,7 @@ export class PerformanceChartDialog {
this.historicalDataItems = this.data.historicalDataItems;
this.historicalDataItems.forEach((historicalDataItem) => {
this.historicalDataItems?.forEach((historicalDataItem) => {
const benchmarkItem = historicalData.find((item) => {
return item.date === historicalDataItem.date;
});
@ -75,7 +75,7 @@ export class PerformanceChartDialog {
}
});
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.title = `Performance vs. ${this.benchmarkLabel}`;

View File

@ -34,7 +34,7 @@ export class PositionDetailDialog {
public transactionCount: number;
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
@ -127,7 +127,7 @@ export class PositionDetailDialog {
this.benchmarkDataItems[0].value = this.averagePrice;
}
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
}
);
}

View File

@ -9,15 +9,17 @@ import { ViewMode } from '@prisma/client';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { DataService } from '../services/data.service';
import { SettingsStorageService } from '../services/settings-storage.service';
import { UserService } from '../services/user/user.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
private static PUBLIC_PAGE_ROUTES = ['/about', '/pricing', '/resources'];
constructor(
private dataService: DataService,
private router: Router,
private settingsStorageService: SettingsStorageService
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
@ -29,11 +31,14 @@ export class AuthGuard implements CanActivate {
}
return new Promise<boolean>((resolve) => {
this.dataService
.fetchUser()
this.userService
.get()
.pipe(
catchError(() => {
if (state.url !== '/start') {
if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) {
resolve(true);
return EMPTY;
} else if (state.url !== '/start') {
this.router.navigate(['/start']);
resolve(false);
return EMPTY;
@ -45,12 +50,12 @@ export class AuthGuard implements CanActivate {
)
.subscribe((user) => {
if (
state.url === '/home' &&
state.url.startsWith('/home') &&
user.settings.viewMode === ViewMode.ZEN
) {
this.router.navigate(['/zen']);
resolve(false);
} else if (state.url === '/start') {
} else if (state.url.startsWith('/start')) {
if (user.settings.viewMode === ViewMode.ZEN) {
this.router.navigate(['/zen']);
} else {
@ -59,7 +64,7 @@ export class AuthGuard implements CanActivate {
resolve(false);
} else if (
state.url === '/zen' &&
state.url.startsWith('/zen') &&
user.settings.viewMode === ViewMode.DEFAULT
) {
this.router.navigate(['/home']);

View File

@ -1,9 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component';
const routes: Routes = [{ path: '', component: AboutPageComponent }];
const routes: Routes = [
{ path: '', component: AboutPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
@ -26,28 +25,23 @@ export class AboutPageComponent implements OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private changeDetectorRef: ChangeDetectorRef,
private userService: UserService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.isLoggedIn = !!this.tokenStorageService.getToken();
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
if (this.isLoggedIn)
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.cd.markForCheck();
});
});
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {

View File

@ -5,15 +5,14 @@
<mat-card class="mb-3">
<mat-card-content>
<p>
<strong>Ghostfolio</strong> ({{ version }}) is open source software
which empowers busy folks to have a sharp look of their financial
assets and to make solid, data-driven investment decisions by
evaluating automated static portfolio analysis rules. The project
has been initiated by
<strong>Ghostfolio</strong> is open source software which empowers
busy folks to have a sharp look of their financial assets and to
make solid, data-driven investment decisions by evaluating automated
static portfolio analysis rules. The project has been initiated by
<a href="https://dotsilver.ch">Thomas Kaul</a>.
<ng-container *ngIf="lastPublish">
This instance has been last published on {{ lastPublish
}}.</ng-container
This instance is running Ghostfolio {{ version }} and has been
last published on {{ lastPublish }}.</ng-container
>
</p>
<p>

View File

@ -1,6 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -28,9 +28,9 @@ export class AccountPageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.dataService
.fetchInfo()
@ -44,20 +44,19 @@ export class AccountPageComponent implements OnDestroy, OnInit {
);
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
@ -78,11 +77,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.userService.remove();
this.cd.markForCheck();
});
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
@ -98,7 +102,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
.subscribe((response) => {
this.accesses = response;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}
}

View File

@ -5,7 +5,7 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Account as AccountModel, AccountType } from '@prisma/client';
@ -35,14 +35,14 @@ export class AccountsPageComponent implements OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
@ -75,23 +75,23 @@ export class AccountsPageComponent implements OnInit {
this.hasImpersonationId = !!aId;
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateAccount = hasPermission(
user.permissions,
this.user.permissions,
permissions.createAccount
);
this.hasPermissionToDeleteAccount = hasPermission(
user.permissions,
this.user.permissions,
permissions.deleteAccount
);
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
this.fetchAccounts();
@ -105,7 +105,7 @@ export class AccountsPageComponent implements OnInit {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { AdminData, User } from '@ghostfolio/common/interfaces';
import { formatDistanceToNow, isValid, parseISO, sub } from 'date-fns';
@ -32,9 +32,9 @@ export class AdminPageComponent implements OnInit {
public constructor(
private adminService: AdminService,
private cacheService: CacheService,
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {}
/**
@ -43,13 +43,12 @@ export class AdminPageComponent implements OnInit {
public ngOnInit() {
this.fetchAdminData();
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
});
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
}
@ -140,7 +139,7 @@ export class AdminPageComponent implements OnInit {
this.transactionCount = transactionCount;
this.userCount = userCount;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
}
);
}

View File

@ -73,26 +73,40 @@
<table class="gf-table">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-center" i18n>#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2" i18n>Registration Date</th>
<th class="mat-header-cell px-1 py-2" i18n>Accounts</th>
<th class="mat-header-cell px-1 py-2" i18n>Transactions</th>
<th class="mat-header-cell px-1 py-2" i18n>Engagement</th>
<th class="mat-header-cell px-1 py-2 text-center" i18n>
Registration Date
</th>
<th class="mat-header-cell px-1 py-2 text-center" i18n>
Accounts
</th>
<th class="mat-header-cell px-1 py-2 text-center" i18n>
Transactions
</th>
<th class="mat-header-cell px-1 py-2 text-center" i18n>
Engagement
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let userItem of users" class="mat-row">
<tr *ngFor="let userItem of users; let i = index" class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2">
{{ userItem.alias || userItem.id }}
</td>
<td class="mat-cell px-1 py-2">
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.createdAt | date: defaultDateFormat }}
</td>
<td class="mat-cell px-1 py-2">{{ userItem._count?.Account }}</td>
<td class="mat-cell px-1 py-2">{{ userItem._count?.Order }}</td>
<td class="mat-cell px-1 py-2">
<td class="mat-cell px-1 py-2 text-right">
{{ userItem._count?.Account }}
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem._count?.Order }}
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.Analytics?.activityCount }}
</td>
<td class="mat-cell px-1 py-2">

View File

@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioItem,
PortfolioPosition,
@ -40,11 +40,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {}
/**
@ -66,7 +66,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.subscribe((response) => {
this.portfolioItems = response;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.dataService
@ -76,18 +76,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.portfolioPositions = response;
this.initializeAnalysisData(this.portfolioPositions, this.period);
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}

View File

@ -10,7 +10,7 @@ import {
RANGE,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioOverview,
PortfolioPerformance,
@ -58,7 +58,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
@ -66,7 +66,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.routeQueryParams = this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
@ -76,14 +76,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
}
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
user.permissions,
this.user.permissions,
permissions.accessFearAndGreedIndex
);
@ -94,17 +94,17 @@ export class HomePageComponent implements OnDestroy, OnInit {
.subscribe(({ marketPrice }) => {
this.fearAndGreedIndex = marketPrice;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}
this.hasPermissionToReadForeignPortfolio = hasPermission(
user.permissions,
this.user.permissions,
permissions.readForeignPortfolio
);
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
@ -169,7 +169,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
};
});
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.dataService
@ -178,14 +178,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.performance = response;
this.isLoadingPerformance = false;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.dataService.fetchPortfolioOverview().subscribe((response) => {
this.overview = response;
this.isLoadingOverview = false;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.dataService
@ -195,9 +195,9 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.hasPositions =
this.positions && Object.keys(this.positions).length > 0;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
}
}

View File

@ -26,7 +26,7 @@ export class LoginPageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private dialog: MatDialog,
private router: Router,
@ -42,7 +42,7 @@ export class LoginPageComponent implements OnDestroy, OnInit {
this.initializeLineChart();
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -16,7 +16,7 @@ export class ShowAccessTokenDialog {
public isAgreeButtonDisabled = true;
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
@ -26,7 +26,7 @@ export class ShowAccessTokenDialog {
setTimeout(() => {
this.isAgreeButtonDisabled = false;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
}, 1500);
}
}

View File

@ -1,9 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { PricingPageComponent } from './pricing-page.component';
const routes: Routes = [{ path: '', component: PricingPageComponent }];
const routes: Routes = [
{ path: '', component: PricingPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
@ -22,28 +21,23 @@ export class PricingPageComponent implements OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private dataService: DataService,
private tokenStorageService: TokenStorageService
private changeDetectorRef: ChangeDetectorRef,
private userService: UserService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.isLoggedIn = !!this.tokenStorageService.getToken();
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
if (this.isLoggedIn)
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.cd.markForCheck();
});
});
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {

View File

@ -20,7 +20,7 @@ export class ReportPageComponent implements OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
) {}
@ -38,7 +38,7 @@ export class ReportPageComponent implements OnInit {
portfolioReport.rules['currencyClusterRisk'] || null;
this.feeRules = portfolioReport.rules['fees'] || null;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -1,9 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ResourcesPageComponent } from './resources-page.component';
const routes: Routes = [{ path: '', component: ResourcesPageComponent }];
const routes: Routes = [
{ path: '', component: ResourcesPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ToolsPageComponent } from './tools-page.component';
const routes: Routes = [
{ path: '', component: ToolsPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ToolsPageRoutingModule {}

View File

@ -0,0 +1,21 @@
import { Component, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
selector: 'gf-tools-page',
templateUrl: './tools-page.html',
styleUrls: ['./tools-page.scss']
})
export class ToolsPageComponent implements OnInit {
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor() {}
/**
* Initializes the controller
*/
public ngOnInit() {}
}

View File

@ -0,0 +1,33 @@
<div class="container">
<h3 class="d-flex justify-content-center mb-3" i18n>Tools</h3>
<div class="row">
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<h4 i18n>Analysis</h4>
<p class="mb-0">
Ghostfolio Analysis shows your positions and visualizes your
portfolio.
</p>
<p class="text-right">
<button color="primary" i18n mat-button [routerLink]="['/analysis']">
Open Analysis →
</button>
</p>
</mat-card>
</div>
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<h4 i18n>X-ray</h4>
<p class="mb-0">
Ghostfolio X-ray uses static analysis to identify potential issues and
risks in your portfolio.
</p>
<p class="text-right">
<button color="primary" i18n mat-button [routerLink]="['/report']">
Open X-ray →
</button>
</p>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { ToolsPageRoutingModule } from './tools-page-routing.module';
import { ToolsPageComponent } from './tools-page.component';
@NgModule({
declarations: [ToolsPageComponent],
exports: [],
imports: [
CommonModule,
MatButtonModule,
MatCardModule,
RouterModule,
ToolsPageRoutingModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ToolsPageModule {}

View File

@ -0,0 +1,8 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -42,7 +42,7 @@ export class CreateOrUpdateTransactionDialog {
private unsubscribeSubject = new Subject<void>();
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
@ -73,7 +73,7 @@ export class CreateOrUpdateTransactionDialog {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
this.currentMarketPrice = marketPrice;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}
}
@ -100,7 +100,7 @@ export class CreateOrUpdateTransactionDialog {
this.isLoading = false;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -5,7 +5,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Order as OrderModel } from '@prisma/client';
@ -35,14 +35,14 @@ export class TransactionsPageComponent implements OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
@ -75,23 +75,23 @@ export class TransactionsPageComponent implements OnInit {
this.hasImpersonationId = !!aId;
});
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
user.permissions,
this.user.permissions,
permissions.createOrder
);
this.hasPermissionToDeleteOrder = hasPermission(
user.permissions,
this.user.permissions,
permissions.deleteOrder
);
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
this.fetchOrders();
@ -105,7 +105,7 @@ export class TransactionsPageComponent implements OnInit {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
}

View File

@ -2,7 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
@ -31,26 +31,25 @@ export class ZenPageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private cd: ChangeDetectorRef,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private tokenStorageService: TokenStorageService
private userService: UserService
) {
this.tokenStorageService
.onChangeHasToken()
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToReadForeignPortfolio = hasPermission(
user.permissions,
this.user.permissions,
permissions.readForeignPortfolio
);
this.cd.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
@ -87,7 +86,7 @@ export class ZenPageComponent implements OnDestroy, OnInit {
};
});
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.dataService
@ -96,9 +95,9 @@ export class ZenPageComponent implements OnDestroy, OnInit {
this.performance = response;
this.isLoadingPerformance = false;
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
});
this.cd.markForCheck();
this.changeDetectorRef.markForCheck();
}
}

View File

@ -74,13 +74,6 @@ export class DataService {
}
public fetchInfo() {
/*
if (this.info) {
// TODO: Cache info
return of(this.info);
}
*/
return this.http.get<InfoItem>('/api/info').pipe(
map((data) => {
if (
@ -154,10 +147,6 @@ export class DataService {
);
}
public fetchUser() {
return this.http.get<User>('/api/user');
}
public loginAnonymous(accessToken: string) {
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`);
}

View File

@ -1,5 +1,4 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
const TOKEN_KEY = 'auth-token';
@ -7,23 +6,15 @@ const TOKEN_KEY = 'auth-token';
providedIn: 'root'
})
export class TokenStorageService {
private hasTokenChangeSubject = new BehaviorSubject<void>(null);
public constructor() {}
public getToken(): string {
return window.localStorage.getItem(TOKEN_KEY);
}
public onChangeHasToken() {
return this.hasTokenChangeSubject.asObservable();
}
public saveToken(token: string): void {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.setItem(TOKEN_KEY, token);
this.hasTokenChangeSubject.next();
}
public signOut(): void {
@ -34,7 +25,5 @@ export class TokenStorageService {
if (utmSource) {
window.localStorage.setItem('utm_source', utmSource);
}
this.hasTokenChangeSubject.next();
}
}

View File

@ -0,0 +1,4 @@
export enum UserStoreActions {
GetUser = 'GET_USER',
RemoveUser = 'REMOVE_USER'
}

View File

@ -0,0 +1,5 @@
import { User } from '@ghostfolio/common/interfaces';
export interface UserStoreState {
user: User;
}

View File

@ -0,0 +1,56 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { ObservableStore } from '@codewithdan/observable-store';
import { User } from '@ghostfolio/common/interfaces';
import { of } from 'rxjs';
import { throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { UserStoreActions } from './user-store.actions';
import { UserStoreState } from './user-store.state';
@Injectable({
providedIn: 'root'
})
export class UserService extends ObservableStore<UserStoreState> {
public constructor(private http: HttpClient) {
super({ trackStateHistory: true });
this.setState({ user: undefined }, 'INIT_STATE');
}
public get() {
const state = this.getState();
if (state?.user) {
// Get from cache
return of(state.user);
} else {
// Get from endpoint
return this.fetchUser().pipe(catchError(this.handleError));
}
}
public remove() {
this.setState({ user: null }, UserStoreActions.RemoveUser);
}
private fetchUser() {
return this.http.get<User>('/api/user').pipe(
map((user) => {
this.setState({ user }, UserStoreActions.GetUser);
return user;
}),
catchError(this.handleError)
);
}
private handleError(error: any) {
if (error.error instanceof Error) {
const errMessage = error.error.message;
return throwError(errMessage);
}
return throwError(error || 'Server error');
}
}

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.5.0",
"version": "1.8.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -55,6 +55,7 @@
"@angular/platform-browser": "11.2.4",
"@angular/platform-browser-dynamic": "11.2.4",
"@angular/router": "11.2.4",
"@codewithdan/observable-store": "2.2.11",
"@nestjs/common": "7.6.5",
"@nestjs/config": "0.6.1",
"@nestjs/core": "7.6.5",

View File

@ -1396,6 +1396,11 @@
exec-sh "^0.3.2"
minimist "^1.2.0"
"@codewithdan/observable-store@2.2.11":
version "2.2.11"
resolved "https://registry.yarnpkg.com/@codewithdan/observable-store/-/observable-store-2.2.11.tgz#f5a168e86a2fa185a50ca40a1e838aa5e5fb007d"
integrity sha512-6CfqLJUqV0SwS4yE+9vciqxHUJ6CqIptSXXzFw80MonCDoVJvCJ/xhKfs7VZqJ4jDtEu/7ILvovFtZdLg9fiAg==
"@ctrl/tinycolor@^2.6.0":
version "2.6.1"
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-2.6.1.tgz#0e78cc836a1fd997a9a22fa1c26c555411882160"