mirror of https://github.com/ghostfolio/ghostfolio
273 changed files with 21774 additions and 4598 deletions
@ -0,0 +1,12 @@ |
|||||
|
import { IsNumber, IsString } from 'class-validator'; |
||||
|
|
||||
|
export class TransferBalanceDto { |
||||
|
@IsString() |
||||
|
accountIdFrom: string; |
||||
|
|
||||
|
@IsString() |
||||
|
accountIdTo: string; |
||||
|
|
||||
|
@IsNumber() |
||||
|
balance: number; |
||||
|
} |
@ -0,0 +1,6 @@ |
|||||
|
import { IsString } from 'class-validator'; |
||||
|
|
||||
|
export class CreateTagDto { |
||||
|
@IsString() |
||||
|
name: string; |
||||
|
} |
@ -0,0 +1,104 @@ |
|||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Delete, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
Put, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { Tag } from '@prisma/client'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
import { CreateTagDto } from './create-tag.dto'; |
||||
|
import { TagService } from './tag.service'; |
||||
|
import { UpdateTagDto } from './update-tag.dto'; |
||||
|
|
||||
|
@Controller('tag') |
||||
|
export class TagController { |
||||
|
public constructor( |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser, |
||||
|
private readonly tagService: TagService |
||||
|
) {} |
||||
|
|
||||
|
@Get() |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async getTags() { |
||||
|
return this.tagService.getTagsWithActivityCount(); |
||||
|
} |
||||
|
|
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async createTag(@Body() data: CreateTagDto): Promise<Tag> { |
||||
|
if (!hasPermission(this.request.user.permissions, permissions.createTag)) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.tagService.createTag(data); |
||||
|
} |
||||
|
|
||||
|
@Put(':id') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { |
||||
|
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const originalTag = await this.tagService.getTag({ |
||||
|
id |
||||
|
}); |
||||
|
|
||||
|
if (!originalTag) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.tagService.updateTag({ |
||||
|
data: { |
||||
|
...data |
||||
|
}, |
||||
|
where: { |
||||
|
id |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Delete(':id') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async deleteTag(@Param('id') id: string) { |
||||
|
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const originalTag = await this.tagService.getTag({ |
||||
|
id |
||||
|
}); |
||||
|
|
||||
|
if (!originalTag) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.tagService.deleteTag({ id }); |
||||
|
} |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { TagController } from './tag.controller'; |
||||
|
import { TagService } from './tag.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [TagController], |
||||
|
exports: [TagService], |
||||
|
imports: [PrismaModule], |
||||
|
providers: [TagService] |
||||
|
}) |
||||
|
export class TagModule {} |
@ -0,0 +1,79 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import { Prisma, Tag } from '@prisma/client'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class TagService { |
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async createTag(data: Prisma.TagCreateInput) { |
||||
|
return this.prismaService.tag.create({ |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> { |
||||
|
return this.prismaService.tag.delete({ where }); |
||||
|
} |
||||
|
|
||||
|
public async getTag( |
||||
|
tagWhereUniqueInput: Prisma.TagWhereUniqueInput |
||||
|
): Promise<Tag> { |
||||
|
return this.prismaService.tag.findUnique({ |
||||
|
where: tagWhereUniqueInput |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getTags({ |
||||
|
cursor, |
||||
|
orderBy, |
||||
|
skip, |
||||
|
take, |
||||
|
where |
||||
|
}: { |
||||
|
cursor?: Prisma.TagWhereUniqueInput; |
||||
|
orderBy?: Prisma.TagOrderByWithRelationInput; |
||||
|
skip?: number; |
||||
|
take?: number; |
||||
|
where?: Prisma.TagWhereInput; |
||||
|
} = {}) { |
||||
|
return this.prismaService.tag.findMany({ |
||||
|
cursor, |
||||
|
orderBy, |
||||
|
skip, |
||||
|
take, |
||||
|
where |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getTagsWithActivityCount() { |
||||
|
const tagsWithOrderCount = await this.prismaService.tag.findMany({ |
||||
|
include: { |
||||
|
_count: { |
||||
|
select: { orders: true } |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return tagsWithOrderCount.map(({ _count, id, name }) => { |
||||
|
return { |
||||
|
id, |
||||
|
name, |
||||
|
activityCount: _count.orders |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async updateTag({ |
||||
|
data, |
||||
|
where |
||||
|
}: { |
||||
|
data: Prisma.TagUpdateInput; |
||||
|
where: Prisma.TagWhereUniqueInput; |
||||
|
}): Promise<Tag> { |
||||
|
return this.prismaService.tag.update({ |
||||
|
data, |
||||
|
where |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -0,0 +1,9 @@ |
|||||
|
import { IsString } from 'class-validator'; |
||||
|
|
||||
|
export class UpdateTagDto { |
||||
|
@IsString() |
||||
|
id: string; |
||||
|
|
||||
|
@IsString() |
||||
|
name: string; |
||||
|
} |
@ -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,85 @@ |
|||||
|
<div class="container"> |
||||
|
<div class="row"> |
||||
|
<div class="col"> |
||||
|
<div class="d-flex justify-content-end"> |
||||
|
<a |
||||
|
color="primary" |
||||
|
i18n |
||||
|
mat-flat-button |
||||
|
[queryParams]="{ createTagDialog: true }" |
||||
|
[routerLink]="[]" |
||||
|
> |
||||
|
Add Tag |
||||
|
</a> |
||||
|
</div> |
||||
|
<table |
||||
|
class="gf-table w-100" |
||||
|
mat-table |
||||
|
matSort |
||||
|
matSortActive="name" |
||||
|
matSortDirection="asc" |
||||
|
[dataSource]="dataSource" |
||||
|
> |
||||
|
<ng-container matColumnDef="name"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="px-1" |
||||
|
mat-header-cell |
||||
|
mat-sort-header="name" |
||||
|
> |
||||
|
<ng-container i18n>Name</ng-container> |
||||
|
</th> |
||||
|
<td *matCellDef="let element" class="px-1" mat-cell> |
||||
|
{{ element.name }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="activities"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="px-1" |
||||
|
mat-header-cell |
||||
|
mat-sort-header="activityCount" |
||||
|
> |
||||
|
<ng-container i18n>Activities</ng-container> |
||||
|
</th> |
||||
|
<td *matCellDef="let element" class="px-1" mat-cell> |
||||
|
{{ element.activityCount }} |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container matColumnDef="actions"> |
||||
|
<th |
||||
|
*matHeaderCellDef |
||||
|
class="px-1 text-center" |
||||
|
i18n |
||||
|
mat-header-cell |
||||
|
></th> |
||||
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell> |
||||
|
<button |
||||
|
class="mx-1 no-min-width px-2" |
||||
|
mat-button |
||||
|
[matMenuTriggerFor]="tagMenu" |
||||
|
(click)="$event.stopPropagation()" |
||||
|
> |
||||
|
<ion-icon name="ellipsis-horizontal"></ion-icon> |
||||
|
</button> |
||||
|
<mat-menu #tagMenu="matMenu" xPosition="before"> |
||||
|
<button mat-menu-item (click)="onUpdateTag(element)"> |
||||
|
<ion-icon class="mr-2" name="create-outline"></ion-icon> |
||||
|
<span i18n>Edit</span> |
||||
|
</button> |
||||
|
<button mat-menu-item (click)="onDeleteTag(element.id)"> |
||||
|
<ion-icon class="mr-2" name="trash-outline"></ion-icon> |
||||
|
<span i18n>Delete</span> |
||||
|
</button> |
||||
|
</mat-menu> |
||||
|
</td> |
||||
|
</ng-container> |
||||
|
|
||||
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> |
||||
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> |
||||
|
</table> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
@ -0,0 +1,5 @@ |
|||||
|
@import 'apps/client/src/styles/ghostfolio-style'; |
||||
|
|
||||
|
:host { |
||||
|
display: block; |
||||
|
} |
@ -0,0 +1,204 @@ |
|||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
OnDestroy, |
||||
|
OnInit, |
||||
|
ViewChild |
||||
|
} from '@angular/core'; |
||||
|
import { MatDialog } from '@angular/material/dialog'; |
||||
|
import { MatSort } from '@angular/material/sort'; |
||||
|
import { MatTableDataSource } from '@angular/material/table'; |
||||
|
import { ActivatedRoute, Router } from '@angular/router'; |
||||
|
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto'; |
||||
|
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto'; |
||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service'; |
||||
|
import { DataService } from '@ghostfolio/client/services/data.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { Tag } from '@prisma/client'; |
||||
|
import { get } from 'lodash'; |
||||
|
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
|
import { Subject, takeUntil } from 'rxjs'; |
||||
|
|
||||
|
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
selector: 'gf-admin-tag', |
||||
|
styleUrls: ['./admin-tag.component.scss'], |
||||
|
templateUrl: './admin-tag.component.html' |
||||
|
}) |
||||
|
export class AdminTagComponent implements OnInit, OnDestroy { |
||||
|
@ViewChild(MatSort) sort: MatSort; |
||||
|
|
||||
|
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource(); |
||||
|
public deviceType: string; |
||||
|
public displayedColumns = ['name', 'activities', 'actions']; |
||||
|
public tags: Tag[]; |
||||
|
|
||||
|
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
|
public constructor( |
||||
|
private adminService: AdminService, |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private dataService: DataService, |
||||
|
private deviceService: DeviceDetectorService, |
||||
|
private dialog: MatDialog, |
||||
|
private route: ActivatedRoute, |
||||
|
private router: Router, |
||||
|
private userService: UserService |
||||
|
) { |
||||
|
this.route.queryParams |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe((params) => { |
||||
|
if (params['createTagDialog']) { |
||||
|
this.openCreateTagDialog(); |
||||
|
} else if (params['editTagDialog']) { |
||||
|
if (this.tags) { |
||||
|
const tag = this.tags.find(({ id }) => { |
||||
|
return id === params['tagId']; |
||||
|
}); |
||||
|
|
||||
|
this.openUpdateTagDialog(tag); |
||||
|
} else { |
||||
|
this.router.navigate(['.'], { relativeTo: this.route }); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
||||
|
|
||||
|
this.fetchTags(); |
||||
|
} |
||||
|
|
||||
|
public onDeleteTag(aId: string) { |
||||
|
const confirmation = confirm( |
||||
|
$localize`Do you really want to delete this tag?` |
||||
|
); |
||||
|
|
||||
|
if (confirmation) { |
||||
|
this.deleteTag(aId); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public onUpdateTag({ id }: Tag) { |
||||
|
this.router.navigate([], { |
||||
|
queryParams: { editTagDialog: true, tagId: id } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.unsubscribeSubject.next(); |
||||
|
this.unsubscribeSubject.complete(); |
||||
|
} |
||||
|
|
||||
|
private deleteTag(aId: string) { |
||||
|
this.adminService |
||||
|
.deleteTag(aId) |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe({ |
||||
|
next: () => { |
||||
|
this.userService |
||||
|
.get(true) |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe(); |
||||
|
|
||||
|
this.fetchTags(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchTags() { |
||||
|
this.adminService |
||||
|
.fetchTags() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe((tags) => { |
||||
|
this.tags = tags; |
||||
|
|
||||
|
this.dataSource = new MatTableDataSource(this.tags); |
||||
|
this.dataSource.sort = this.sort; |
||||
|
this.dataSource.sortingDataAccessor = get; |
||||
|
|
||||
|
this.dataService.updateInfo(); |
||||
|
|
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openCreateTagDialog() { |
||||
|
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, { |
||||
|
data: { |
||||
|
tag: { |
||||
|
name: null |
||||
|
} |
||||
|
}, |
||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe((data) => { |
||||
|
const tag: CreateTagDto = data?.tag; |
||||
|
|
||||
|
if (tag) { |
||||
|
this.adminService |
||||
|
.postTag(tag) |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe({ |
||||
|
next: () => { |
||||
|
this.userService |
||||
|
.get(true) |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe(); |
||||
|
|
||||
|
this.fetchTags(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
this.router.navigate(['.'], { relativeTo: this.route }); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openUpdateTagDialog({ id, name }) { |
||||
|
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, { |
||||
|
data: { |
||||
|
tag: { |
||||
|
id, |
||||
|
name |
||||
|
} |
||||
|
}, |
||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe((data) => { |
||||
|
const tag: UpdateTagDto = data?.tag; |
||||
|
|
||||
|
if (tag) { |
||||
|
this.adminService |
||||
|
.putTag(tag) |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe({ |
||||
|
next: () => { |
||||
|
this.userService |
||||
|
.get(true) |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe(); |
||||
|
|
||||
|
this.fetchTags(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
this.router.navigate(['.'], { relativeTo: this.route }); |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -0,0 +1,26 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatMenuModule } from '@angular/material/menu'; |
||||
|
import { MatSortModule } from '@angular/material/sort'; |
||||
|
import { MatTableModule } from '@angular/material/table'; |
||||
|
import { RouterModule } from '@angular/router'; |
||||
|
|
||||
|
import { AdminTagComponent } from './admin-tag.component'; |
||||
|
import { GfCreateOrUpdateTagDialogModule } from './create-or-update-tag-dialog/create-or-update-tag-dialog.module'; |
||||
|
|
||||
|
@NgModule({ |
||||
|
declarations: [AdminTagComponent], |
||||
|
exports: [AdminTagComponent], |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
GfCreateOrUpdateTagDialogModule, |
||||
|
MatButtonModule, |
||||
|
MatMenuModule, |
||||
|
MatSortModule, |
||||
|
MatTableModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
||||
|
}) |
||||
|
export class GfAdminTagModule {} |
@ -0,0 +1,30 @@ |
|||||
|
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; |
||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; |
||||
|
import { Subject } from 'rxjs'; |
||||
|
|
||||
|
import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'h-100' }, |
||||
|
selector: 'gf-create-or-update-tag-dialog', |
||||
|
styleUrls: ['./create-or-update-tag-dialog.scss'], |
||||
|
templateUrl: 'create-or-update-tag-dialog.html' |
||||
|
}) |
||||
|
export class CreateOrUpdateTagDialog { |
||||
|
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
|
public constructor( |
||||
|
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams, |
||||
|
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog> |
||||
|
) {} |
||||
|
|
||||
|
public onCancel() { |
||||
|
this.dialogRef.close(); |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.unsubscribeSubject.next(); |
||||
|
this.unsubscribeSubject.complete(); |
||||
|
} |
||||
|
} |
@ -0,0 +1,23 @@ |
|||||
|
<form #addTagForm="ngForm" class="d-flex flex-column h-100"> |
||||
|
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1> |
||||
|
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1> |
||||
|
<div class="flex-grow-1 py-3" mat-dialog-content> |
||||
|
<div> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Name</mat-label> |
||||
|
<input matInput name="name" required [(ngModel)]="data.tag.name" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="justify-content-end" mat-dialog-actions> |
||||
|
<button i18n mat-button (click)="onCancel()">Cancel</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
[disabled]="!addTagForm.form.valid" |
||||
|
[mat-dialog-close]="data" |
||||
|
> |
||||
|
<ng-container i18n>Save</ng-container> |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
@ -0,0 +1,23 @@ |
|||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { NgModule } from '@angular/core'; |
||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialogModule } from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { MatInputModule } from '@angular/material/input'; |
||||
|
|
||||
|
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog.component'; |
||||
|
|
||||
|
@NgModule({ |
||||
|
declarations: [CreateOrUpdateTagDialog], |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
FormsModule, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
MatInputModule, |
||||
|
ReactiveFormsModule |
||||
|
] |
||||
|
}) |
||||
|
export class GfCreateOrUpdateTagDialogModule {} |
@ -0,0 +1,5 @@ |
|||||
|
import { Tag } from '@prisma/client'; |
||||
|
|
||||
|
export interface CreateOrUpdateTagDialogParams { |
||||
|
tag: Tag; |
||||
|
} |
@ -0,0 +1,7 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
|
||||
|
.mat-mdc-dialog-content { |
||||
|
max-height: unset; |
||||
|
} |
||||
|
} |
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue