mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
147 changed files with 6683 additions and 1396 deletions
@ -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> |
@ -0,0 +1,30 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { NgModule } from '@angular/core'; |
|||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { MatCardModule } from '@angular/material/card'; |
|||
import { MatCheckboxModule } from '@angular/material/checkbox'; |
|||
import { MatFormFieldModule } from '@angular/material/form-field'; |
|||
import { MatSelectModule } from '@angular/material/select'; |
|||
import { RouterModule } from '@angular/router'; |
|||
import { GfValueModule } from '@ghostfolio/ui/value'; |
|||
|
|||
import { UserAccountSettingsComponent } from './user-account-settings.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [UserAccountSettingsComponent], |
|||
exports: [UserAccountSettingsComponent], |
|||
imports: [ |
|||
CommonModule, |
|||
FormsModule, |
|||
GfValueModule, |
|||
MatButtonModule, |
|||
MatCardModule, |
|||
MatCheckboxModule, |
|||
MatFormFieldModule, |
|||
MatSelectModule, |
|||
ReactiveFormsModule, |
|||
RouterModule |
|||
] |
|||
}) |
|||
export class GfUserAccountSettingsModule {} |
@ -0,0 +1,13 @@ |
|||
:host { |
|||
color: rgb(var(--dark-primary-text)); |
|||
display: block; |
|||
|
|||
.hint-text { |
|||
font-size: 90%; |
|||
line-height: 1.2; |
|||
} |
|||
} |
|||
|
|||
:host-context(.is-dark-theme) { |
|||
color: rgb(var(--light-primary-text)); |
|||
} |
@ -0,0 +1,14 @@ |
|||
import { Component } from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { RouterModule } from '@angular/router'; |
|||
|
|||
@Component({ |
|||
host: { class: 'page' }, |
|||
imports: [MatButtonModule, RouterModule], |
|||
selector: 'gf-hacktoberfest-2023-page', |
|||
standalone: true, |
|||
templateUrl: './hacktoberfest-2023-page.html' |
|||
}) |
|||
export class Hacktoberfest2023PageComponent { |
|||
public routerLinkAbout = ['/' + $localize`about`]; |
|||
} |
@ -0,0 +1,194 @@ |
|||
<div class="blog container"> |
|||
<div class="row"> |
|||
<div class="col-md-8 offset-md-2"> |
|||
<article> |
|||
<div class="mb-4 text-center"> |
|||
<h1 class="mb-1">Hacktoberfest 2023</h1> |
|||
<div class="mb-3 text-muted"><small>2023-09-26</small></div> |
|||
<img |
|||
alt="Hacktoberfest 2023 with Ghostfolio Teaser" |
|||
class="rounded w-100" |
|||
src="../assets/images/blog/hacktoberfest-2023.png" |
|||
title="Hacktoberfest 2023 with Ghostfolio" |
|||
/> |
|||
</div> |
|||
<section class="mb-4"> |
|||
<p> |
|||
At Ghostfolio, <a [routerLink]="routerLinkAbout">we</a> are very |
|||
excited to participate in |
|||
<a href="https://hacktoberfest.com">Hacktoberfest</a> for the second |
|||
time, looking forward to connecting with new and enthusiastic |
|||
open-source contributors. Hacktoberfest is a month-long celebration |
|||
of open-source projects, their maintainers, and the entire community |
|||
of contributors. Each October, open source maintainers from all over |
|||
the world give extra attention to new contributors while guiding |
|||
them through their first pull requests on |
|||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. This |
|||
year the event celebrates its 10th anniversary. |
|||
</p> |
|||
</section> |
|||
<section class="mb-4"> |
|||
<h2 class="h4">About Ghostfolio</h2> |
|||
<p> |
|||
<a href="https://ghostfol.io">Ghostfolio</a> is a modern web |
|||
application for managing personal finances. The software aggregates |
|||
your assets and empowers informed decision-making to help you |
|||
balance your portfolio or plan for future investments. |
|||
</p> |
|||
<p> |
|||
Ghostfolio is written in |
|||
<a href="https://www.typescriptlang.org">TypeScript</a> and |
|||
organized as an <a href="https://nx.dev">Nx</a> workspace, utilizing |
|||
the latest framework releases. The backend is based on |
|||
<a href="https://nestjs.com">NestJS</a> in combination with |
|||
<a href="https://www.postgresql.org">PostgreSQL</a> as a database |
|||
together with <a href="https://www.prisma.io">Prisma</a> and |
|||
<a href="https://redis.io">Redis</a> for caching. The frontend is |
|||
built with <a href="https://angular.io">Angular</a>. |
|||
</p> |
|||
<p> |
|||
The software is used daily by a thriving global community. With over |
|||
<a [routerLink]="['/open']">2’600 stars on GitHub</a> and |
|||
<a [routerLink]="['/open']">300’000+ pulls on Docker Hub</a>, |
|||
Ghostfolio has gained widespread recognition for its user-friendly |
|||
experience and simplicity. |
|||
</p> |
|||
</section> |
|||
<section class="mb-4"> |
|||
<h2 class="h4">How to contribute?</h2> |
|||
<p> |
|||
Each contribution can make a meaningful impact. Whether it involves |
|||
implementing new features, resolving bugs, refactoring code, |
|||
enhancing documentation, adding unit tests, or translating content |
|||
into another language, you can actively shape our project. |
|||
</p> |
|||
<p> |
|||
Are you not yet familiar with our code base? That is not a problem. |
|||
We have applied the label <code>hacktoberfest</code> to a few |
|||
<a |
|||
href="https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest" |
|||
>issues</a |
|||
> |
|||
that are well suited for newcomers. |
|||
</p> |
|||
<p> |
|||
The official Hacktoberfest website provides some valuable |
|||
<a |
|||
href="https://hacktoberfest.com/participation/#beginner-resources" |
|||
>resources for beginners</a |
|||
> |
|||
to start contributing in open source. |
|||
</p> |
|||
</section> |
|||
<section class="mb-4"> |
|||
<h2 class="h4">Get support</h2> |
|||
<p> |
|||
If you have further questions or ideas, please join our |
|||
<a |
|||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" |
|||
>Slack</a |
|||
> |
|||
community or get in touch on X |
|||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. |
|||
</p> |
|||
<p> |
|||
We look forward to hearing from you.<br /> |
|||
Thomas from Ghostfolio |
|||
</p> |
|||
</section> |
|||
<section class="mb-4"> |
|||
<ul class="list-inline"> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Angular</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Community</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Docker</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Finance</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Fintech</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Ghostfolio</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">GitHub</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Hacktoberfest</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Investment</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">NestJS</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Nx</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">October</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Open Source</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">OSS</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Personal Finance</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Portfolio</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Portfolio Tracker</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Prisma</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Software</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">TypeScript</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">UX</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Wealth</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Wealth Management</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Web3</span> |
|||
</li> |
|||
<li class="list-inline-item"> |
|||
<span class="badge badge-light">Web 3.0</span> |
|||
</li> |
|||
</ul> |
|||
</section> |
|||
<nav aria-label="breadcrumb"> |
|||
<ol class="breadcrumb"> |
|||
<li class="breadcrumb-item"> |
|||
<a i18n [routerLink]="['/blog']">Blog</a> |
|||
</li> |
|||
<li |
|||
aria-current="page" |
|||
class="active breadcrumb-item text-truncate" |
|||
> |
|||
Hacktoberfest 2023 |
|||
</li> |
|||
</ol> |
|||
</nav> |
|||
</article> |
|||
</div> |
|||
</div> |
|||
</div> |
@ -0,0 +1,31 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { Component } from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { RouterModule } from '@angular/router'; |
|||
|
|||
import { products } from '../products'; |
|||
|
|||
@Component({ |
|||
host: { class: 'page' }, |
|||
imports: [CommonModule, MatButtonModule, RouterModule], |
|||
selector: 'gf-finary-page', |
|||
standalone: true, |
|||
styleUrls: ['../product-page-template.scss'], |
|||
templateUrl: '../product-page-template.html' |
|||
}) |
|||
export class FinaryPageComponent { |
|||
public product1 = products.find(({ key }) => { |
|||
return key === 'ghostfolio'; |
|||
}); |
|||
|
|||
public product2 = products.find(({ key }) => { |
|||
return key === 'finary'; |
|||
}); |
|||
|
|||
public routerLinkAbout = ['/' + $localize`about`]; |
|||
public routerLinkFeatures = ['/' + $localize`features`]; |
|||
public routerLinkResourcesPersonalFinanceTools = [ |
|||
'/' + $localize`resources`, |
|||
'personal-finance-tools' |
|||
]; |
|||
} |
@ -0,0 +1,31 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { Component } from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { RouterModule } from '@angular/router'; |
|||
|
|||
import { products } from '../products'; |
|||
|
|||
@Component({ |
|||
host: { class: 'page' }, |
|||
imports: [CommonModule, MatButtonModule, RouterModule], |
|||
selector: 'gf-stockle-page', |
|||
standalone: true, |
|||
styleUrls: ['../product-page-template.scss'], |
|||
templateUrl: '../product-page-template.html' |
|||
}) |
|||
export class StocklePageComponent { |
|||
public product1 = products.find(({ key }) => { |
|||
return key === 'ghostfolio'; |
|||
}); |
|||
|
|||
public product2 = products.find(({ key }) => { |
|||
return key === 'stockle'; |
|||
}); |
|||
|
|||
public routerLinkAbout = ['/' + $localize`about`]; |
|||
public routerLinkFeatures = ['/' + $localize`features`]; |
|||
public routerLinkResourcesPersonalFinanceTools = [ |
|||
'/' + $localize`resources`, |
|||
'personal-finance-tools' |
|||
]; |
|||
} |
@ -1,448 +1,63 @@ |
|||
import { |
|||
ChangeDetectorRef, |
|||
Component, |
|||
OnDestroy, |
|||
OnInit, |
|||
ViewChild |
|||
} from '@angular/core'; |
|||
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox'; |
|||
import { MatDialog } from '@angular/material/dialog'; |
|||
import { |
|||
MatSnackBar, |
|||
MatSnackBarRef, |
|||
TextOnlySnackBar |
|||
} from '@angular/material/snack-bar'; |
|||
import { ActivatedRoute, Router } from '@angular/router'; |
|||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; |
|||
import { DataService } from '@ghostfolio/client/services/data.service'; |
|||
import { |
|||
STAY_SIGNED_IN, |
|||
SettingsStorageService |
|||
} from '@ghostfolio/client/services/settings-storage.service'; |
|||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; |
|||
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
|||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; |
|||
import { downloadAsFile, getDateFormatString } from '@ghostfolio/common/helper'; |
|||
import { Access, User } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { format, parseISO } from 'date-fns'; |
|||
import { uniq } from 'lodash'; |
|||
import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; |
|||
import { DeviceDetectorService } from 'ngx-device-detector'; |
|||
import { StripeService } from 'ngx-stripe'; |
|||
import { EMPTY, Subject } from 'rxjs'; |
|||
import { catchError, switchMap, takeUntil } from 'rxjs/operators'; |
|||
|
|||
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; |
|||
import { Subject, takeUntil } from 'rxjs'; |
|||
|
|||
@Component({ |
|||
host: { class: 'page' }, |
|||
host: { class: 'page has-tabs' }, |
|||
selector: 'gf-user-account-page', |
|||
styleUrls: ['./user-account-page.scss'], |
|||
templateUrl: './user-account-page.html' |
|||
}) |
|||
export class UserAccountPageComponent implements OnDestroy, OnInit { |
|||
@ViewChild('toggleSignInWithFingerprintEnabledElement') |
|||
signInWithFingerprintElement: MatCheckbox; |
|||
|
|||
public accesses: Access[]; |
|||
public appearancePlaceholder = $localize`Auto`; |
|||
public baseCurrency: string; |
|||
public coupon: number; |
|||
public couponId: string; |
|||
public currencies: string[] = []; |
|||
public defaultDateFormat: string; |
|||
public deviceType: string; |
|||
public hasPermissionForSubscription: boolean; |
|||
public hasPermissionToCreateAccess: boolean; |
|||
public hasPermissionToDeleteAccess: boolean; |
|||
public hasPermissionToUpdateViewMode: boolean; |
|||
public hasPermissionToUpdateUserSettings: boolean; |
|||
public language = document.documentElement.lang; |
|||
public locales = [ |
|||
'de', |
|||
'de-CH', |
|||
'en-GB', |
|||
'en-US', |
|||
'es', |
|||
'fr', |
|||
'it', |
|||
'nl', |
|||
'pt', |
|||
'tr' |
|||
]; |
|||
public price: number; |
|||
public priceId: string; |
|||
public snackBarRef: MatSnackBarRef<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 tabs: TabConfiguration[] = []; |
|||
public user: User; |
|||
|
|||
private unsubscribeSubject = new Subject<void>(); |
|||
|
|||
public constructor( |
|||
private changeDetectorRef: ChangeDetectorRef, |
|||
private dataService: DataService, |
|||
private deviceService: DeviceDetectorService, |
|||
private dialog: MatDialog, |
|||
private snackBar: MatSnackBar, |
|||
private route: ActivatedRoute, |
|||
private router: Router, |
|||
private settingsStorageService: SettingsStorageService, |
|||
private stripeService: StripeService, |
|||
private userService: UserService, |
|||
public webAuthnService: WebAuthnService |
|||
private userService: UserService |
|||
) { |
|||
const { baseCurrency, currencies, globalPermissions, subscriptions } = |
|||
this.dataService.fetchInfo(); |
|||
|
|||
this.baseCurrency = baseCurrency; |
|||
this.currencies = currencies; |
|||
|
|||
this.hasPermissionForSubscription = hasPermission( |
|||
globalPermissions, |
|||
permissions.enableSubscription |
|||
); |
|||
|
|||
this.hasPermissionToDeleteAccess = hasPermission( |
|||
globalPermissions, |
|||
permissions.deleteAccess |
|||
); |
|||
|
|||
this.userService.stateChanged |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((state) => { |
|||
if (state?.user) { |
|||
this.user = state.user; |
|||
|
|||
this.defaultDateFormat = getDateFormatString( |
|||
this.user.settings.locale |
|||
); |
|||
|
|||
this.hasPermissionToCreateAccess = hasPermission( |
|||
this.user.permissions, |
|||
permissions.createAccess |
|||
); |
|||
|
|||
this.hasPermissionToDeleteAccess = hasPermission( |
|||
this.user.permissions, |
|||
permissions.deleteAccess |
|||
); |
|||
|
|||
this.hasPermissionToUpdateUserSettings = hasPermission( |
|||
this.user.permissions, |
|||
permissions.updateUserSettings |
|||
); |
|||
|
|||
this.hasPermissionToUpdateViewMode = hasPermission( |
|||
this.user.permissions, |
|||
permissions.updateViewMode |
|||
); |
|||
|
|||
this.locales.push(this.user.settings.locale); |
|||
this.locales = uniq(this.locales.sort()); |
|||
|
|||
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon; |
|||
this.couponId = |
|||
subscriptions?.[this.user.subscription.offer]?.couponId; |
|||
this.price = subscriptions?.[this.user.subscription.offer]?.price; |
|||
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; |
|||
this.tabs = [ |
|||
{ |
|||
iconName: 'settings-outline', |
|||
label: $localize`Settings`, |
|||
path: ['/account'] |
|||
}, |
|||
{ |
|||
iconName: 'diamond-outline', |
|||
label: $localize`Membership`, |
|||
path: ['/account/membership'], |
|||
showCondition: !!this.user?.subscription |
|||
}, |
|||
{ |
|||
iconName: 'share-social-outline', |
|||
label: $localize`Access`, |
|||
path: ['/account', 'access'] |
|||
} |
|||
]; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
} |
|||
}); |
|||
|
|||
this.route.queryParams |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((params) => { |
|||
if (params['createDialog']) { |
|||
this.openCreateAccessDialog(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
|||
|
|||
this.update(); |
|||
} |
|||
|
|||
public onChangeUserSetting(aKey: string, aValue: string) { |
|||
this.dataService |
|||
.putUserSetting({ [aKey]: aValue }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
|
|||
if (aKey === 'language') { |
|||
if (aValue) { |
|||
window.location.href = `../${aValue}/account`; |
|||
} else { |
|||
window.location.href = `../`; |
|||
} |
|||
} |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onCheckout() { |
|||
this.dataService |
|||
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) |
|||
.pipe( |
|||
switchMap(({ sessionId }: { sessionId: string }) => { |
|||
return this.stripeService.redirectToCheckout({ sessionId }); |
|||
}), |
|||
catchError((error) => { |
|||
alert(error.message); |
|||
throw error; |
|||
}) |
|||
) |
|||
.subscribe((result) => { |
|||
if (result.error) { |
|||
alert(result.error.message); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public onDeleteAccess(aId: string) { |
|||
this.dataService |
|||
.deleteAccess(aId) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: () => { |
|||
this.update(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) { |
|||
this.dataService |
|||
.putUserSetting({ isExperimentalFeatures: aEvent.checked }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onExport() { |
|||
this.dataService |
|||
.fetchExport() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((data) => { |
|||
for (const activity of data.activities) { |
|||
delete activity.id; |
|||
} |
|||
|
|||
downloadAsFile({ |
|||
content: data, |
|||
fileName: `ghostfolio-export-${format( |
|||
parseISO(data.meta.date), |
|||
'yyyyMMddHHmm' |
|||
)}.json`,
|
|||
format: 'json' |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onRedeemCoupon() { |
|||
let couponCode = prompt($localize`Please enter your coupon code:`); |
|||
couponCode = couponCode?.trim(); |
|||
|
|||
if (couponCode) { |
|||
this.dataService |
|||
.redeemCoupon(couponCode) |
|||
.pipe( |
|||
takeUntil(this.unsubscribeSubject), |
|||
catchError(() => { |
|||
this.snackBar.open( |
|||
'😞 ' + $localize`Could not redeem coupon code`, |
|||
undefined, |
|||
{ |
|||
duration: 3000 |
|||
} |
|||
); |
|||
|
|||
return EMPTY; |
|||
}) |
|||
) |
|||
.subscribe(() => { |
|||
this.snackBarRef = this.snackBar.open( |
|||
'✅ ' + $localize`Coupon code has been redeemed`, |
|||
$localize`Reload`, |
|||
{ |
|||
duration: 3000 |
|||
} |
|||
); |
|||
|
|||
this.snackBarRef |
|||
.afterDismissed() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
window.location.reload(); |
|||
}); |
|||
|
|||
this.snackBarRef |
|||
.onAction() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
window.location.reload(); |
|||
}); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
public onRestrictedViewChange(aEvent: MatCheckboxChange) { |
|||
this.dataService |
|||
.putUserSetting({ isRestrictedView: aEvent.checked }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) { |
|||
if (aEvent.checked) { |
|||
this.registerDevice(); |
|||
} else { |
|||
const confirmation = confirm( |
|||
$localize`Do you really want to remove this sign in method?` |
|||
); |
|||
|
|||
if (confirmation) { |
|||
this.deregisterDevice(); |
|||
} else { |
|||
this.update(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public onViewModeChange(aEvent: MatCheckboxChange) { |
|||
this.dataService |
|||
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe(() => { |
|||
this.userService.remove(); |
|||
|
|||
this.userService |
|||
.get() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((user) => { |
|||
this.user = user; |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
public ngOnDestroy() { |
|||
this.unsubscribeSubject.next(); |
|||
this.unsubscribeSubject.complete(); |
|||
} |
|||
|
|||
private openCreateAccessDialog(): void { |
|||
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, { |
|||
data: { |
|||
access: { |
|||
alias: '', |
|||
type: 'PUBLIC' |
|||
} |
|||
}, |
|||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', |
|||
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
|||
}); |
|||
|
|||
dialogRef |
|||
.afterClosed() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((data: any) => { |
|||
const access: CreateAccessDto = data?.access; |
|||
|
|||
if (access) { |
|||
this.dataService |
|||
.postAccess({ alias: access.alias }) |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe({ |
|||
next: () => { |
|||
this.update(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
this.router.navigate(['.'], { relativeTo: this.route }); |
|||
}); |
|||
} |
|||
|
|||
private deregisterDevice() { |
|||
this.webAuthnService |
|||
.deregister() |
|||
.pipe( |
|||
takeUntil(this.unsubscribeSubject), |
|||
catchError(() => { |
|||
this.update(); |
|||
|
|||
return EMPTY; |
|||
}) |
|||
) |
|||
.subscribe(() => { |
|||
this.update(); |
|||
}); |
|||
} |
|||
|
|||
private registerDevice() { |
|||
this.webAuthnService |
|||
.register() |
|||
.pipe( |
|||
takeUntil(this.unsubscribeSubject), |
|||
catchError(() => { |
|||
this.update(); |
|||
|
|||
return EMPTY; |
|||
}) |
|||
) |
|||
.subscribe(() => { |
|||
this.settingsStorageService.removeSetting(STAY_SIGNED_IN); |
|||
|
|||
this.update(); |
|||
}); |
|||
} |
|||
|
|||
private update() { |
|||
this.dataService |
|||
.fetchAccesses() |
|||
.pipe(takeUntil(this.unsubscribeSubject)) |
|||
.subscribe((response) => { |
|||
this.accesses = response; |
|||
|
|||
if (this.signInWithFingerprintElement) { |
|||
this.signInWithFingerprintElement.checked = |
|||
this.webAuthnService.isEnabled() ?? false; |
|||
} |
|||
|
|||
this.changeDetectorRef.markForCheck(); |
|||
}); |
|||
} |
|||
} |
|||
|
@ -1,309 +1,29 @@ |
|||
<div class="container"> |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<h2 class="h3 mb-3 text-center" i18n>Account</h2> |
|||
</div> |
|||
</div> |
|||
<div *ngIf="user?.settings" class="mb-5 row"> |
|||
<div class="col"> |
|||
<mat-card appearance="outlined" class="mb-3"> |
|||
<mat-card-content> |
|||
<div *ngIf="user?.subscription" class="d-flex py-1"> |
|||
<div class="pr-1 w-50" i18n>Membership</div> |
|||
<div class="pl-1 w-50"> |
|||
<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 class="align-items-center d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Presenter View</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Protection for sensitive information like absolute performances |
|||
and quantity values |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.isRestrictedView" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onRestrictedViewChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="d-flex mt-4 py-1"> |
|||
<form #changeUserSettingsForm="ngForm" class="w-100"> |
|||
<div class="d-flex mb-2"> |
|||
<div class="align-items-center d-flex pt-1 pt-1 w-50"> |
|||
<ng-container i18n>Base Currency</ng-container> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field |
|||
appearance="outline" |
|||
class="w-100 without-hint" |
|||
> |
|||
<mat-select |
|||
name="baseCurrency" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="user.settings.baseCurrency" |
|||
(selectionChange)="onChangeUserSetting('baseCurrency', $event.value)" |
|||
> |
|||
<mat-option |
|||
*ngFor="let currency of currencies" |
|||
[value]="currency" |
|||
>{{ currency }}</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mb-2"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Language</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field |
|||
appearance="outline" |
|||
class="w-100 without-hint" |
|||
> |
|||
<mat-select |
|||
name="language" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="language" |
|||
(selectionChange)="onChangeUserSetting('language', $event.value)" |
|||
> |
|||
<mat-option [value]="null"></mat-option> |
|||
<mat-option value="de">Deutsch</mat-option> |
|||
<mat-option value="en">English</mat-option> |
|||
<mat-option value="es" |
|||
>Español (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="fr" |
|||
>Français (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="it" |
|||
>Italiano (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="nl" |
|||
>Nederlands (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="pt" |
|||
>Português (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
<mat-option value="tr" |
|||
>Türkçe (<ng-container i18n>Community</ng-container |
|||
>)</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mb-2"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Locale</div> |
|||
<div class="hint-text text-muted"> |
|||
<ng-container i18n>Date and number format</ng-container> |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field |
|||
appearance="outline" |
|||
class="w-100 without-hint" |
|||
> |
|||
<mat-select |
|||
name="locale" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[value]="user.settings.locale" |
|||
(selectionChange)="onChangeUserSetting('locale', $event.value)" |
|||
> |
|||
<mat-option [value]="null"></mat-option> |
|||
<mat-option |
|||
*ngFor="let locale of locales" |
|||
[value]="locale" |
|||
>{{ locale }}</mat-option |
|||
> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="d-flex"> |
|||
<div class="align-items-center d-flex pr-1 pt-1 w-50"> |
|||
<ng-container i18n>Appearance</ng-container> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-form-field |
|||
appearance="outline" |
|||
class="w-100 without-hint" |
|||
> |
|||
<mat-select |
|||
class="with-placeholder-as-option" |
|||
name="colorScheme" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
[placeholder]="appearancePlaceholder" |
|||
[value]="user?.settings?.colorScheme" |
|||
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)" |
|||
> |
|||
<mat-option i18n [value]="null">Auto</mat-option> |
|||
<mat-option i18n value="LIGHT">Light</mat-option> |
|||
<mat-option i18n value="DARK">Dark</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<div class="d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Zen Mode</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Distraction-free experience for turbulent times |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.viewMode === 'ZEN'" |
|||
[disabled]="!hasPermissionToUpdateViewMode" |
|||
(change)="onViewModeChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Biometric Authentication</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Sign in with fingerprint |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
#toggleSignInWithFingerprintEnabledElement |
|||
color="primary" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onSignInWithFingerprintChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div |
|||
*ngIf="hasPermissionToUpdateUserSettings" |
|||
class="align-items-center d-flex mt-4 py-1" |
|||
> |
|||
<div class="pr-1 w-50"> |
|||
<div i18n>Experimental Features</div> |
|||
<div class="hint-text text-muted" i18n> |
|||
Sneak peek at upcoming functionality |
|||
</div> |
|||
</div> |
|||
<div class="pl-1 w-50"> |
|||
<mat-checkbox |
|||
color="primary" |
|||
[checked]="user.settings.isExperimentalFeatures" |
|||
[disabled]="!hasPermissionToUpdateUserSettings" |
|||
(change)="onExperimentalFeaturesChange($event)" |
|||
></mat-checkbox> |
|||
</div> |
|||
</div> |
|||
<div class="align-items-center d-flex mt-4 py-1"> |
|||
<div class="pr-1 w-50" i18n>User ID</div> |
|||
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div> |
|||
</div> |
|||
<div class="align-items-center d-flex py-1"> |
|||
<div class="pr-1 w-50"></div> |
|||
<div class="pl-1 text-monospace w-50"> |
|||
<button color="primary" mat-flat-button (click)="onExport()"> |
|||
<span i18n>Export Data</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</mat-card-content> |
|||
</mat-card> |
|||
</div> |
|||
</div> |
|||
<div class="row"> |
|||
<div class="col"> |
|||
<h2 class="align-items-center d-flex h3 justify-content-center mb-3"> |
|||
<span i18n>Granted Access</span> |
|||
<gf-premium-indicator |
|||
*ngIf="user?.subscription?.type === 'Basic'" |
|||
class="ml-1" |
|||
></gf-premium-indicator> |
|||
</h2> |
|||
<gf-access-table |
|||
[accesses]="accesses" |
|||
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess" |
|||
[showActions]="hasPermissionToDeleteAccess" |
|||
(accessDeleted)="onDeleteAccess($event)" |
|||
></gf-access-table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<mat-tab-nav-panel #tabPanel class="flex-grow-1 overflow-auto"> |
|||
<router-outlet></router-outlet> |
|||
</mat-tab-nav-panel> |
|||
|
|||
<nav |
|||
mat-align-tabs="center" |
|||
mat-tab-nav-bar |
|||
[disablePagination]="true" |
|||
[tabPanel]="tabPanel" |
|||
> |
|||
<ng-container *ngFor="let tab of tabs"> |
|||
<a |
|||
#rla="routerLinkActive" |
|||
*ngIf="tab.showCondition !== false" |
|||
class="px-3" |
|||
mat-tab-link |
|||
routerLinkActive |
|||
[active]="rla.isActive" |
|||
[routerLink]="tab.path" |
|||
[routerLinkActiveOptions]="{ exact: true }" |
|||
> |
|||
<ion-icon |
|||
[name]="tab.iconName" |
|||
[size]="deviceType === 'mobile' ? 'large': 'small'" |
|||
></ion-icon> |
|||
<div class="d-none d-sm-block ml-2">{{ tab.label }}</div> |
|||
</a> |
|||
</ng-container> |
|||
</nav> |
|||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue