Browse Source

Refactor access management: update access handling and permissions, enhance UI interactions for editing access

pull/5566/head
Germán Martín 3 months ago
parent
commit
568c4cc8f3
  1. 45
      apps/api/src/app/access/access.controller.ts
  2. 15
      apps/api/src/app/access/access.service.ts
  3. 3
      apps/api/src/app/access/update-access.dto.ts
  4. 5
      apps/client/src/app/components/access-table/access-table.component.html
  5. 6
      apps/client/src/app/components/access-table/access-table.component.ts
  6. 99
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  7. 8
      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. 42
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  10. 2
      apps/client/src/app/components/user-account-access/user-account-access.html
  11. 12
      apps/client/src/app/services/data.service.ts
  12. 3
      libs/common/src/lib/permissions.ts

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

@ -34,21 +34,6 @@ export class AccessController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@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;
}
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAllAccesses(): Promise<Access[]> { public async getAllAccesses(): Promise<Access[]> {
@ -56,7 +41,10 @@ export class AccessController {
include: { include: {
granteeUser: true granteeUser: true
}, },
orderBy: { granteeUserId: 'asc' }, orderBy: [
{ granteeUserId: 'desc' }, // NULL values first (public access), then user IDs
{ createdAt: 'asc' } // Within each group, order by creation time
],
where: { userId: this.request.user.id } where: { userId: this.request.user.id }
}); });
@ -120,9 +108,12 @@ export class AccessController {
@HasPermission(permissions.deleteAccess) @HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> { public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
const access = await this.accessService.access({ id }); const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!access || access.userId !== this.request.user.id) { if (!originalAccess) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -134,6 +125,7 @@ export class AccessController {
}); });
} }
@HasPermission(permissions.updateAccess)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateAccess( public async updateAccess(
@ -150,9 +142,12 @@ export class AccessController {
); );
} }
const access = await this.accessService.access({ id }); const originalAccess = await this.accessService.access({
id,
userId: this.request.user.id
});
if (!access || access.userId !== this.request.user.id) { if (!originalAccess) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -160,16 +155,16 @@ export class AccessController {
} }
try { try {
return this.accessService.updateAccess( return this.accessService.updateAccess({
{ id }, data: {
{
alias: data.alias, alias: data.alias,
granteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: { disconnect: true }, : { disconnect: true },
permissions: data.permissions permissions: data.permissions
} },
); where: { id }
});
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),

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

@ -25,7 +25,9 @@ export class AccessService {
take?: number; take?: number;
cursor?: Prisma.AccessWhereUniqueInput; cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput; where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByWithRelationInput; orderBy?:
| Prisma.AccessOrderByWithRelationInput
| Prisma.AccessOrderByWithRelationInput[];
}): Promise<AccessWithGranteeUser[]> { }): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
@ -53,10 +55,13 @@ export class AccessService {
}); });
} }
public async updateAccess( public async updateAccess({
where: Prisma.AccessWhereUniqueInput, data,
data: Prisma.AccessUpdateInput where
): Promise<Access> { }: {
data: Prisma.AccessUpdateInput;
where: Prisma.AccessWhereUniqueInput;
}): Promise<Access> {
return this.prismaService.access.update({ return this.prismaService.access.update({
where, where,
data data

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

@ -2,6 +2,9 @@ import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateAccessDto { export class UpdateAccessDto {
@IsString()
id: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
alias?: string; alias?: string;

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

@ -65,12 +65,14 @@
<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)"> @if (user?.settings?.isExperimentalFeatures) {
<button mat-menu-item (click)="onUpdateAccess(element.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" /> <ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </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">
@ -79,7 +81,6 @@
</span> </span>
</button> </button>
} }
<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" />

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

@ -54,7 +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>(); @Output() accessToUpdate = new EventEmitter<string>();
public baseUrl = window.location.origin; public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>; public dataSource: MatTableDataSource<Access>;
@ -116,7 +116,7 @@ export class GfAccessTableComponent implements OnChanges {
}); });
} }
public onEditAccess(aId: string) { public onUpdateAccess(aId: string) {
this.accessEdited.emit(aId); this.accessToUpdate.emit(aId);
} }
} }

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

@ -49,9 +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 OnInit, OnDestroy { export class GfCreateOrUpdateAccessDialog implements OnDestroy, OnInit {
public accessForm: FormGroup; public accessForm: FormGroup;
public isEditMode: boolean; public mode: 'create' | 'update';
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -63,44 +63,11 @@ export class GfCreateOrUpdateAccessDialog implements OnInit, OnDestroy {
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService private notificationService: NotificationService
) { ) {
this.isEditMode = !!data.accessId; this.mode = this.data.access?.id ? 'update' : 'create';
} }
private async createAccess() { public onCancel() {
console.log('Creating access...'); this.dialogRef.close();
const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value,
permissions: [this.accessForm.get('permissions').value]
};
try {
await validateObjectForForm({
classDto: CreateAccessDto,
form: this.accessForm,
object: access
});
this.dataService
.postAccess(access)
.pipe(
catchError((error) => {
if (error.status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({
title: $localize`Oops! Could not grant access.`
});
}
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close(access);
});
} catch (error) {
console.error(error);
}
} }
public ngOnDestroy() { public ngOnDestroy() {
@ -114,7 +81,7 @@ export class GfCreateOrUpdateAccessDialog implements OnInit, OnDestroy {
granteeUserId: [this.data.access.grantee, Validators.required], granteeUserId: [this.data.access.grantee, Validators.required],
permissions: [this.data.access.permissions[0], Validators.required], permissions: [this.data.access.permissions[0], Validators.required],
type: [ type: [
{ value: this.data.access.type, disabled: this.isEditMode }, { value: this.data.access.type, disabled: this.mode === 'update' },
Validators.required Validators.required
] ]
}); });
@ -145,34 +112,58 @@ export class GfCreateOrUpdateAccessDialog implements OnInit, OnDestroy {
} }
} }
public onCancel() {
this.dialogRef.close();
}
public async onSubmit() { public async onSubmit() {
if (!this.accessForm.valid) { if (this.mode === 'update') {
console.error('Form is invalid:', this.accessForm.errors);
return;
}
if (this.isEditMode) {
await this.updateAccess(); await this.updateAccess();
} else { } else {
await this.createAccess(); await this.createAccess();
} }
} }
private async createAccess() {
const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value,
permissions: [this.accessForm.get('permissions').value]
};
try {
await validateObjectForForm({
classDto: CreateAccessDto,
form: this.accessForm,
object: access
});
this.dataService
.postAccess(access)
.pipe(
catchError((error) => {
if (error.status === StatusCodes.BAD_REQUEST) {
this.notificationService.alert({
title: $localize`Oops! Could not grant access.`
});
}
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close(access);
});
} catch (error) {
console.error(error);
}
}
private async updateAccess() { private async updateAccess() {
console.log('Updating access...');
const access: UpdateAccessDto = { const access: UpdateAccessDto = {
id: this.data.access.id,
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);
console.log('Access ID:', this.data.accessId);
try { try {
await validateObjectForForm({ await validateObjectForForm({
classDto: UpdateAccessDto, classDto: UpdateAccessDto,
@ -181,7 +172,7 @@ export class GfCreateOrUpdateAccessDialog implements OnInit, OnDestroy {
}); });
this.dataService this.dataService
.putAccess(this.data.accessId, access) .putAccess(access)
.pipe( .pipe(
catchError((error) => { catchError((error) => {
if (error.status === StatusCodes.BAD_REQUEST) { if (error.status === StatusCodes.BAD_REQUEST) {

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

@ -5,7 +5,7 @@
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
<h1 mat-dialog-title> <h1 mat-dialog-title>
@if (isEditMode) { @if (mode === 'update') {
<span i18n>Edit access</span> <span i18n>Edit access</span>
} @else { } @else {
<span i18n>Grant access</span> <span i18n>Grant access</span>
@ -73,10 +73,12 @@
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]=" [disabled]="
isEditMode ? !accessForm.valid : !(accessForm.dirty && accessForm.valid) mode === 'update'
? !accessForm.valid
: !(accessForm.dirty && accessForm.valid)
" "
> >
@if (isEditMode) { @if (mode === 'update') {
<ng-container i18n>Update</ng-container> <ng-container i18n>Update</ng-container>
} @else { } @else {
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>

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

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

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

@ -115,6 +115,8 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
.subscribe((params) => { .subscribe((params) => {
if (params['createDialog']) { if (params['createDialog']) {
this.openCreateAccessDialog(); this.openCreateAccessDialog();
} else if (params['editDialog']) {
this.openUpdateAccessDialog(params['editDialog']);
} }
}); });
@ -138,8 +140,8 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}); });
} }
public onEditAccess(aId: string) { public onUpdateAccess(aId: string) {
this.openEditAccessDialog(aId); this.openUpdateAccessDialog(aId);
} }
public onGenerateAccessToken() { public onGenerateAccessToken() {
@ -204,22 +206,29 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
}); });
} }
private openEditAccessDialog(accessId: string) { private openUpdateAccessDialog(accessId: string) {
// Fetch the access details first // Find the access details in the already loaded data
this.dataService const accessDetails = this.accessesGive.find(
.fetchAccess(accessId) (access) => access.id === accessId
.pipe(takeUntil(this.unsubscribeSubject)) );
.subscribe({
next: (accessDetails) => { if (!accessDetails) {
this.notificationService.alert({
title: $localize`Oops! Could not find access details.`
});
return;
}
const dialogRef = this.dialog.open(GfCreateOrUpdateAccessDialog, { const dialogRef = this.dialog.open(GfCreateOrUpdateAccessDialog, {
data: { data: {
access: { access: {
id: accessDetails.id,
alias: accessDetails.alias, alias: accessDetails.alias,
permissions: accessDetails.permissions, permissions: accessDetails.permissions,
type: accessDetails.granteeUser ? 'PRIVATE' : 'PUBLIC', type: accessDetails.type,
grantee: accessDetails.granteeUser?.id || null grantee:
}, accessDetails.grantee === 'Public' ? null : accessDetails.grantee
accessId: accessId }
}, },
height: this.deviceType === 'mobile' ? '98vh' : undefined, height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
@ -230,13 +239,6 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
this.update(); this.update();
} }
}); });
},
error: () => {
this.notificationService.alert({
title: $localize`Oops! Could not load access details.`
});
}
});
} }
private update() { private update() {

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

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

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

@ -54,7 +54,6 @@ 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,
@ -336,10 +335,6 @@ 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');
} }
@ -798,11 +793,8 @@ export class DataService {
return this.http.post('/api/v1/watchlist', watchlistItem); return this.http.post('/api/v1/watchlist', watchlistItem);
} }
public putAccess(id: string, aAccess: UpdateAccessDto) { public putAccess(aAccess: UpdateAccessDto) {
return this.http.put<AccessWithGranteeUser>( return this.http.put<Access>(`/api/v1/access/${aAccess.id}`, aAccess);
`/api/v1/access/${id}`,
aAccess
);
} }
public putAccount(aAccount: UpdateAccountDto) { public putAccount(aAccount: UpdateAccountDto) {

3
libs/common/src/lib/permissions.ts

@ -49,6 +49,7 @@ export const permissions = {
syncDemoUserAccount: 'syncDemoUserAccount', syncDemoUserAccount: 'syncDemoUserAccount',
toggleReadOnlyMode: 'toggleReadOnlyMode', toggleReadOnlyMode: 'toggleReadOnlyMode',
updateAccount: 'updateAccount', updateAccount: 'updateAccount',
updateAccess: 'updateAccess',
updateAuthDevice: 'updateAuthDevice', updateAuthDevice: 'updateAuthDevice',
updateMarketData: 'updateMarketData', updateMarketData: 'updateMarketData',
updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile',
@ -93,6 +94,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.readTags, permissions.readTags,
permissions.readWatchlist, permissions.readWatchlist,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAccess,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateMarketData, permissions.updateMarketData,
permissions.updateMarketDataOfOwnAssetProfile, permissions.updateMarketDataOfOwnAssetProfile,
@ -133,6 +135,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.readWatchlist, permissions.readWatchlist,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAccess,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateMarketDataOfOwnAssetProfile, permissions.updateMarketDataOfOwnAssetProfile,
permissions.updateOrder, permissions.updateOrder,

Loading…
Cancel
Save