mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
273 changed files with 21768 additions and 4590 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; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,197 @@ |
|||
<div class="container"> |
|||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Settings</h1> |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<div class="align-items-center d-flex py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Presenter View</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Protection for sensitive information like absolute performances and |
|||
quantity values |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.isRestrictedView" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onRestrictedViewChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="d-flex mt-4 py-1"> |
|||
<form #changeUserSettingsForm="ngForm" class="w-100"> |
|||
<div class="d-flex mb-2"> |
|||
<div class="align-items-center d-flex pt-1 pt-1 w-50"> |
|||
<ng-container i18n>Base Currency</ng-container> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field appearance="outline" class="w-100 without-hint"> |
|||
<mat-select |
|||
name="baseCurrency" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="user.settings.baseCurrency" |
|||
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)" |
|||
> |
|||
<mat-option |
|||
*ngFor="let currency of currencies" |
|||
[value]="currency" |
|||
>{{ currency }}</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mb-2"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Language</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field appearance="outline" class="w-100 without-hint"> |
|||
<mat-select |
|||
name="language" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="language" |
|||
(selectionChange)="onChangeUserSetting('language', $event.value)" |
|||
> |
|||
<mat-option [value]="null"></mat-option> |
|||
<mat-option value="de">Deutsch</mat-option> |
|||
<mat-option value="en">English</mat-option> |
|||
<mat-option value="es" |
|||
>Español (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="fr" |
|||
>Français (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="it" |
|||
>Italiano (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="nl" |
|||
>Nederlands (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="pt" |
|||
>Português (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="tr" |
|||
>Türkçe (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mb-2"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Locale</div> |
|||
<div class="hint-text text-muted"> |
|||
<ng-container i18n>Date and number format</ng-container> |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field appearance="outline" class="w-100 without-hint"> |
|||
<mat-select |
|||
name="locale" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="user.settings.locale" |
|||
(selectionChange)="onChangeUserSetting('locale', $event.value)" |
|||
> |
|||
<mat-option [value]="null"></mat-option> |
|||
<mat-option *ngFor="let locale of locales" [value]="locale" |
|||
>{{ locale }}</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="d-flex"> |
|||
<div class="align-items-center d-flex pr-1 pt-1 w-50"> |
|||
<ng-container i18n>Appearance</ng-container> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field appearance="outline" class="w-100 without-hint"> |
|||
<mat-select |
|||
class="with-placeholder-as-option" |
|||
name="colorScheme" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[placeholder]="appearancePlaceholder" |
|||
[value]="user?.settings?.colorScheme" |
|||
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)" |
|||
> |
|||
<mat-option i18n [value]="null">Auto</mat-option> |
|||
<mat-option i18n value="LIGHT">Light</mat-option> |
|||
<mat-option i18n value="DARK">Dark</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<div class="d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Zen Mode</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Distraction-free experience for turbulent times |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.viewMode === 'ZEN'" |
|||
[disabled]="!hasPermissionToUpdateViewMode" |
|||
(change)="onViewModeChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Biometric Authentication</div> |
|||
<div class="hint-text text-muted" i18n>Sign in with fingerprint</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
#toggleSignInWithFingerprintEnabledElement |
|||
color="primary" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onSignInWithFingerprintChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div |
|||
*ngIf="hasPermissionToUpdateUserSettings" |
|||
class="align-items-center d-flex mt-4 py-1" |
|||
> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Experimental Features</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Sneak peek at upcoming functionality |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.isExperimentalFeatures" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onExperimentalFeaturesChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50" i18n>User ID</div> |
|||
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div> |
|||
</div> |
|||
<div class="align-items-center d-flex py-1"> |
|||
<div class="pr-1 w-50"></div> |
|||
<div class="pl-1 text-monospace w-50"> |
|||
<button color="primary" mat-flat-button (click)="onExport()"> |
|||
<span i18n>Export Data</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue