Browse Source

Feature/add update access functionality and edit dialog support

pull/5566/head
Germán Martín 1 month ago
parent
commit
60e6b31688
  1. 61
      apps/api/src/app/access/access.controller.ts
  2. 10
      apps/api/src/app/access/access.service.ts
  3. 16
      apps/api/src/app/access/update-access.dto.ts
  4. 8
      apps/client/src/app/components/access-table/access-table.component.html
  5. 7
      apps/client/src/app/components/access-table/access-table.component.ts
  6. 79
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  7. 16
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  8. 1
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
  9. 39
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  10. 1
      apps/client/src/app/components/user-account-access/user-account-access.html
  11. 13
      apps/client/src/app/services/data.service.ts

61
apps/api/src/app/access/access.controller.ts

@ -14,6 +14,7 @@ import {
Inject, Inject,
Param, Param,
Post, Post,
Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -23,6 +24,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto'; import { CreateAccessDto } from './create-access.dto';
import { UpdateAccessDto } from './update-access.dto';
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {
@ -66,6 +68,21 @@ export class AccessController {
); );
} }
@Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAccess(@Param('id') id: string): Promise<AccessModel> {
const access = await this.accessService.access({ id });
if (!access || access.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return access;
}
@HasPermission(permissions.createAccess) @HasPermission(permissions.createAccess)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -99,6 +116,50 @@ export class AccessController {
} }
} }
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateAccess(
@Param('id') id: string,
@Body() data: UpdateAccessDto
): Promise<AccessModel> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const access = await this.accessService.access({ id });
if (!access || access.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return this.accessService.updateAccess(
{ id },
{
alias: data.alias,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: { disconnect: true },
permissions: data.permissions
}
);
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteAccess) @HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

10
apps/api/src/app/access/access.service.ts

@ -52,4 +52,14 @@ export class AccessService {
where where
}); });
} }
public async updateAccess(
where: Prisma.AccessWhereUniqueInput,
data: Prisma.AccessUpdateInput
): Promise<Access> {
return this.prismaService.access.update({
where,
data
});
}
} }

16
apps/api/src/app/access/update-access.dto.ts

@ -0,0 +1,16 @@
import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateAccessDto {
@IsOptional()
@IsString()
alias?: string;
@IsOptional()
@IsUUID()
granteeUserId?: string;
@IsEnum(AccessPermission, { each: true })
@IsOptional()
permissions?: AccessPermission[];
}

8
apps/client/src/app/components/access-table/access-table.component.html

@ -65,6 +65,12 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #transactionMenu="matMenu" xPosition="before"> <mat-menu #transactionMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onEditAccess(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="pencil-outline" />
<span i18n>Edit</span>
</span>
</button>
@if (element.type === 'PUBLIC') { @if (element.type === 'PUBLIC') {
<button mat-menu-item (click)="onCopyUrlToClipboard(element.id)"> <button mat-menu-item (click)="onCopyUrlToClipboard(element.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@ -72,8 +78,8 @@
<span i18n>Copy link to clipboard</span> <span i18n>Copy link to clipboard</span>
</span> </span>
</button> </button>
<hr class="my-0" />
} }
<hr class="my-0" />
<button mat-menu-item (click)="onDeleteAccess(element.id)"> <button mat-menu-item (click)="onDeleteAccess(element.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="remove-circle-outline" /> <ion-icon class="mr-2" name="remove-circle-outline" />

7
apps/client/src/app/components/access-table/access-table.component.ts

@ -27,6 +27,7 @@ import {
linkOutline, linkOutline,
lockClosedOutline, lockClosedOutline,
lockOpenOutline, lockOpenOutline,
pencilOutline,
removeCircleOutline removeCircleOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms'; import ms from 'ms';
@ -53,6 +54,7 @@ export class GfAccessTableComponent implements OnChanges {
@Input() user: User; @Input() user: User;
@Output() accessDeleted = new EventEmitter<string>(); @Output() accessDeleted = new EventEmitter<string>();
@Output() accessEdited = new EventEmitter<string>();
public baseUrl = window.location.origin; public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>; public dataSource: MatTableDataSource<Access>;
@ -69,6 +71,7 @@ export class GfAccessTableComponent implements OnChanges {
linkOutline, linkOutline,
lockClosedOutline, lockClosedOutline,
lockOpenOutline, lockOpenOutline,
pencilOutline,
removeCircleOutline removeCircleOutline
}); });
} }
@ -112,4 +115,8 @@ export class GfAccessTableComponent implements OnChanges {
title: $localize`Do you really want to revoke this granted access?` title: $localize`Do you really want to revoke this granted access?`
}); });
} }
public onEditAccess(aId: string) {
this.accessEdited.emit(aId);
}
} }

79
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -1,4 +1,5 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util'; import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
@ -8,7 +9,8 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Inject, Inject,
OnDestroy OnDestroy,
OnInit
} from '@angular/core'; } from '@angular/core';
import { import {
FormBuilder, FormBuilder,
@ -47,8 +49,9 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-access-dialog.scss'], styleUrls: ['./create-or-update-access-dialog.scss'],
templateUrl: 'create-or-update-access-dialog.html' templateUrl: 'create-or-update-access-dialog.html'
}) })
export class GfCreateOrUpdateAccessDialog implements OnDestroy { export class GfCreateOrUpdateAccessDialog implements OnInit, OnDestroy {
public accessForm: FormGroup; public accessForm: FormGroup;
public isEditMode: boolean;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -59,9 +62,14 @@ export class GfCreateOrUpdateAccessDialog implements OnDestroy {
private dataService: DataService, private dataService: DataService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService private notificationService: NotificationService
) {} ) {
this.isEditMode = !!data.accessId;
}
public ngOnInit() { public ngOnInit() {
console.log('Dialog init - Edit mode:', this.isEditMode);
console.log('Dialog data:', this.data);
this.accessForm = this.formBuilder.group({ this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias], alias: [this.data.access.alias],
permissions: [this.data.access.permissions[0], Validators.required], permissions: [this.data.access.permissions[0], Validators.required],
@ -77,6 +85,7 @@ export class GfCreateOrUpdateAccessDialog implements OnDestroy {
granteeUserIdControl.setValidators(Validators.required); granteeUserIdControl.setValidators(Validators.required);
} else { } else {
granteeUserIdControl.clearValidators(); granteeUserIdControl.clearValidators();
granteeUserIdControl.setValue(null);
permissionsControl.setValue(this.data.access.permissions[0]); permissionsControl.setValue(this.data.access.permissions[0]);
} }
@ -84,6 +93,14 @@ export class GfCreateOrUpdateAccessDialog implements OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
// Initial validation setup based on current type
if (this.accessForm.get('type').value === 'PUBLIC') {
const granteeUserIdControl = this.accessForm.get('granteeUserId');
granteeUserIdControl.clearValidators();
granteeUserIdControl.setValue(null);
granteeUserIdControl.updateValueAndValidity();
}
} }
public onCancel() { public onCancel() {
@ -91,12 +108,28 @@ export class GfCreateOrUpdateAccessDialog implements OnDestroy {
} }
public async onSubmit() { public async onSubmit() {
if (!this.accessForm.valid) {
console.error('Form is invalid:', this.accessForm.errors);
return;
}
if (this.isEditMode) {
await this.updateAccess();
} else {
await this.createAccess();
}
}
private async createAccess() {
console.log('Creating access...');
const access: CreateAccessDto = { const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value, alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value, granteeUserId: this.accessForm.get('granteeUserId').value,
permissions: [this.accessForm.get('permissions').value] permissions: [this.accessForm.get('permissions').value]
}; };
console.log('Access data:', access);
try { try {
await validateObjectForForm({ await validateObjectForForm({
classDto: CreateAccessDto, classDto: CreateAccessDto,
@ -126,6 +159,46 @@ export class GfCreateOrUpdateAccessDialog implements OnDestroy {
} }
} }
private async updateAccess() {
console.log('Updating access...');
const access: UpdateAccessDto = {
alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value,
permissions: [this.accessForm.get('permissions').value]
};
console.log('Access data:', access);
console.log('Access ID:', this.data.accessId);
try {
await validateObjectForForm({
classDto: UpdateAccessDto,
form: this.accessForm,
object: access
});
this.dataService
.putAccess(this.data.accessId, access)
.pipe(
catchError((error) => {
if (error.status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({
title: $localize`Oops! Could not update access.`
});
}
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close(access);
});
} catch (error) {
console.error(error);
}
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

16
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -4,7 +4,13 @@
(keyup.enter)="accessForm.valid && onSubmit()" (keyup.enter)="accessForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
<h1 i18n mat-dialog-title>Grant access</h1> <h1 mat-dialog-title>
@if (isEditMode) {
<span i18n>Edit access</span>
} @else {
<span i18n>Grant access</span>
}
</h1>
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
@ -66,9 +72,13 @@
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!(accessForm.dirty && accessForm.valid)" [disabled]="isEditMode ? !accessForm.valid : !(accessForm.dirty && accessForm.valid)"
> >
<ng-container i18n>Save</ng-container> @if (isEditMode) {
<ng-container i18n>Update</ng-container>
} @else {
<ng-container i18n>Save</ng-container>
}
</button> </button>
</div> </div>
</form> </form>

1
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts

@ -2,4 +2,5 @@ import { Access } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams { export interface CreateOrUpdateAccessDialogParams {
access: Access; access: Access;
accessId?: string;
} }

39
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -138,6 +138,10 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}); });
} }
public onEditAccess(aId: string) {
this.openEditAccessDialog(aId);
}
public onGenerateAccessToken() { public onGenerateAccessToken() {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
@ -200,6 +204,41 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}); });
} }
private openEditAccessDialog(accessId: string) {
// Fetch the access details first
this.dataService
.fetchAccess(accessId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: (accessDetails) => {
const dialogRef = this.dialog.open(GfCreateOrUpdateAccessDialog, {
data: {
access: {
alias: accessDetails.alias,
permissions: accessDetails.permissions,
type: accessDetails.granteeUser ? 'PRIVATE' : 'PUBLIC',
grantee: accessDetails.granteeUser?.id || null
},
accessId: accessId
},
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.update();
}
});
},
error: () => {
this.notificationService.alert({
title: $localize`Oops! Could not load access details.`
});
}
});
}
private update() { private update() {
this.accessesGet = this.user.access.map(({ alias, id, permissions }) => { this.accessesGet = this.user.access.map(({ alias, id, permissions }) => {
return { return {

1
apps/client/src/app/components/user-account-access/user-account-access.html

@ -64,6 +64,7 @@
[showActions]="hasPermissionToDeleteAccess" [showActions]="hasPermissionToDeleteAccess"
[user]="user" [user]="user"
(accessDeleted)="onDeleteAccess($event)" (accessDeleted)="onDeleteAccess($event)"
(accessEdited)="onEditAccess($event)"
/> />
@if (hasPermissionToCreateAccess) { @if (hasPermissionToCreateAccess) {
<div class="fab-container"> <div class="fab-container">

13
apps/client/src/app/services/data.service.ts

@ -1,4 +1,5 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto';
import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto'; import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto'; import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
@ -53,6 +54,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import type { import type {
AccessWithGranteeUser,
AccountWithValue, AccountWithValue,
AiPromptMode, AiPromptMode,
DateRange, DateRange,
@ -334,6 +336,10 @@ export class DataService {
return this.http.delete<any>(`/api/v1/watchlist/${dataSource}/${symbol}`); return this.http.delete<any>(`/api/v1/watchlist/${dataSource}/${symbol}`);
} }
public fetchAccess(id: string) {
return this.http.get<AccessWithGranteeUser>(`/api/v1/access/${id}`);
}
public fetchAccesses() { public fetchAccesses() {
return this.http.get<Access[]>('/api/v1/access'); return this.http.get<Access[]>('/api/v1/access');
} }
@ -743,6 +749,13 @@ export class DataService {
return this.http.post<OrderModel>('/api/v1/access', aAccess); return this.http.post<OrderModel>('/api/v1/access', aAccess);
} }
public putAccess(id: string, aAccess: UpdateAccessDto) {
return this.http.put<AccessWithGranteeUser>(
`/api/v1/access/${id}`,
aAccess
);
}
public postAccount(aAccount: CreateAccountDto) { public postAccount(aAccount: CreateAccountDto) {
return this.http.post<OrderModel>('/api/v1/account', aAccount); return this.http.post<OrderModel>('/api/v1/account', aAccount);
} }

Loading…
Cancel
Save