Browse Source

Add expiration date to access

pull/7709/head
Thomas Kaul 3 days ago
parent
commit
1454ac3d80
  1. 4
      apps/api/src/app/access/access.service.ts
  2. 2
      apps/api/src/app/user/user.service.ts
  3. 34
      apps/api/src/services/impersonation/impersonation.service.spec.ts
  4. 4
      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. 4
      apps/client/src/app/components/header/header.component.html
  7. 8
      apps/client/src/app/components/header/header.component.ts
  8. 13
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  9. 3
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  10. 5
      libs/common/src/lib/dtos/create-access.dto.ts
  11. 5
      libs/common/src/lib/dtos/update-access.dto.ts
  12. 16
      libs/common/src/lib/validator-constraints/is-in-the-future.ts

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

@ -4,7 +4,7 @@ import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Access, Prisma } from '@prisma/client'; import { Access, Prisma } from '@prisma/client';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday, isValid } from 'date-fns';
@Injectable() @Injectable()
export class AccessService { export class AccessService {
@ -62,7 +62,7 @@ export class AccessService {
} }
public isExpired({ expiresAt }: Pick<Access, 'expiresAt'>) { public isExpired({ expiresAt }: Pick<Access, 'expiresAt'>) {
return isBefore(expiresAt, new Date()); return isBefore(expiresAt, new Date()) || !isValid(expiresAt);
} }
public async updateAccess({ public async updateAccess({

2
apps/api/src/app/user/user.service.ts

@ -138,7 +138,7 @@ export class UserService {
user: true user: true
}, },
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { granteeUserId: id } where: { expiresAt: { gt: new Date() }, granteeUserId: id }
}), }),
this.prismaService.account.findMany({ this.prismaService.account.findMany({
include: { platform: true }, include: { platform: true },

34
apps/api/src/services/impersonation/impersonation.service.spec.ts

@ -136,6 +136,7 @@ describe('Impersonation service', () => {
describe('With an impersonation', () => { describe('With an impersonation', () => {
const grantedAccess = { const grantedAccess = {
expiresAt: addDays(new Date(), 1),
granteeUserId: authenticatedUserId, granteeUserId: authenticatedUserId,
id: accessId, id: accessId,
scopes: [scopes.portfolioRead], scopes: [scopes.portfolioRead],
@ -265,6 +266,7 @@ describe('Impersonation service', () => {
// the access itself is the credential // the access itself is the credential
describe('With an access as the credential', () => { describe('With an access as the credential', () => {
const accessOfMcp = { const accessOfMcp = {
expiresAt: addDays(new Date(), 1),
granteeUserId: null, granteeUserId: null,
id: accessId, id: accessId,
scopes: [scopes.portfolioRead], scopes: [scopes.portfolioRead],
@ -337,6 +339,33 @@ describe('Impersonation service', () => {
expect(userId).toBeUndefined(); expect(userId).toBeUndefined();
}); });
it('Refuses an access which has expired', async () => {
const { isActive, userId } = await createService({
access: {
...accessOfMcp,
expiresAt: subDays(new Date(), 1)
} as unknown as Access,
impersonatedUser
}).service.resolve({ impersonationId: accessId, types: ['MCP'] });
expect(isActive).toEqual(false);
expect(userId).toBeUndefined();
});
it('Does not record the usage of an access which has expired', async () => {
const { service, updateAccess } = createService({
access: {
...accessOfMcp,
expiresAt: subDays(new Date(), 1)
} as unknown as Access,
impersonatedUser
});
await service.resolve({ impersonationId: accessId, types: ['MCP'] });
expect(updateAccess).not.toHaveBeenCalled();
});
// The identifier is the one of the access and not the one of the user who // The identifier is the one of the access and not the one of the user who
// granted it, hence an access can never be resolved by another identifier // granted it, hence an access can never be resolved by another identifier
it('Refuses the identifier of another access', async () => { it('Refuses the identifier of another access', async () => {
@ -366,8 +395,6 @@ describe('Impersonation service', () => {
}); });
}); });
// The guard rejects the request in this case, hence the context must not
// present the data of the authenticated user as impersonated data
describe('With an expiration date', () => { describe('With an expiration date', () => {
const expiringAccess = { const expiringAccess = {
granteeUserId: authenticatedUserId, granteeUserId: authenticatedUserId,
@ -451,6 +478,7 @@ describe('Impersonation service', () => {
}; };
const usedAccess = { const usedAccess = {
expiresAt: addDays(new Date(), 1),
granteeUserId: authenticatedUserId, granteeUserId: authenticatedUserId,
id: accessId, id: accessId,
scopes: [scopes.portfolioRead], scopes: [scopes.portfolioRead],
@ -505,6 +533,8 @@ describe('Impersonation service', () => {
}); });
}); });
// The guard rejects the request in this case, hence the context must not
// present the data of the authenticated user as impersonated data
describe('With an identifier which cannot be resolved', () => { describe('With an identifier which cannot be resolved', () => {
it('Resolves the own access instead', async () => { it('Resolves the own access instead', async () => {
const { service } = createService(); const { service } = createService();

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

@ -31,7 +31,7 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Last Used</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Last Used</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.lastUsedAt) { @if (element.lastUsedAt) {
{{ element.lastUsedAt | date: defaultDateFormat }} {{ element.lastUsedAt | date: defaultDateFormat() }}
} @else { } @else {
<span></span> <span></span>
} }
@ -41,7 +41,7 @@
<ng-container matColumnDef="expiresAt"> <ng-container matColumnDef="expiresAt">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Expiration</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Expiration</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
{{ element.expiresAt | date: defaultDateFormat }} {{ element.expiresAt | date: defaultDateFormat() }}
</td> </td>
</ng-container> </ng-container>

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

@ -1,6 +1,6 @@
import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getDateFormatString, getLocale } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
@ -97,7 +97,10 @@ export class GfAccessTableComponent {
return columns; return columns;
}); });
protected readonly defaultDateFormat = getDateFormatString(getLocale()); protected readonly defaultDateFormat = computed(() => {
return getDateFormatString(this.user()?.settings?.locale);
});
protected readonly getAccessLevel = getAccessLevel; protected readonly getAccessLevel = getAccessLevel;
protected hasPermissionToEnableMcp = false; protected hasPermissionToEnableMcp = false;

4
apps/client/src/app/components/header/header.component.html

@ -203,7 +203,7 @@
></a> ></a>
<hr class="m-0" /> <hr class="m-0" />
} }
@if (accesses()?.length > 0) { @if (user()?.access?.length > 0) {
<button mat-menu-item (click)="impersonateAccount(null)"> <button mat-menu-item (click)="impersonateAccount(null)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon
@ -217,7 +217,7 @@
<span i18n>Me</span> <span i18n>Me</span>
</span> </span>
</button> </button>
@for (accessItem of accesses(); track accessItem.id) { @for (accessItem of user()?.access; track accessItem.id) {
<button mat-menu-item (click)="impersonateAccount(accessItem.id)"> <button mat-menu-item (click)="impersonateAccount(accessItem.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon

8
apps/client/src/app/components/header/header.component.ts

@ -26,7 +26,6 @@ import { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef, DestroyRef,
HostListener, HostListener,
@ -44,7 +43,6 @@ import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { isBefore } from 'date-fns';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
@ -95,12 +93,6 @@ export class GfHeaderComponent implements OnChanges {
protected readonly assistentMenuTriggerElement = protected readonly assistentMenuTriggerElement =
viewChild.required<MatMenuTrigger>('assistantTrigger'); viewChild.required<MatMenuTrigger>('assistantTrigger');
protected readonly accesses = computed(() => {
return this.user()?.access?.filter(({ expiresAt }) => {
return !isBefore(expiresAt, new Date());
});
});
protected hasFilters: boolean; protected hasFilters: boolean;
protected hasImpersonationId: boolean; protected hasImpersonationId: boolean;
protected hasPermissionForAuthGoogle: boolean; protected hasPermissionForAuthGoogle: boolean;

13
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 { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -41,6 +42,7 @@ import {
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { DateAdapter } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
@ -50,7 +52,7 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { addYears, endOfDay, startOfDay } from 'date-fns'; import { addYears, endOfDay, isBefore, startOfDay } from 'date-fns';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { EMPTY, catchError } from 'rxjs'; import { EMPTY, catchError } from 'rxjs';
@ -64,8 +66,8 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
GfAccessLevelIconComponent, GfAccessLevelIconComponent,
GfPortfolioFilterFormComponent, GfPortfolioFilterFormComponent,
MatButtonModule, MatButtonModule,
MatDialogModule,
MatDatepickerModule, MatDatepickerModule,
MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatSelectModule, MatSelectModule,
@ -93,6 +95,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
private readonly data = private readonly data =
inject<CreateOrUpdateAccessDialogParams>(MAT_DIALOG_DATA); inject<CreateOrUpdateAccessDialogParams>(MAT_DIALOG_DATA);
private readonly dateAdapter = inject<DateAdapter<Date, string>>(DateAdapter);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@ -150,6 +153,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
] ]
}); });
if (access?.expiresAt && isBefore(new Date(access.expiresAt), this.today)) {
this.accessForm.get('expiresAt')?.markAsTouched();
}
this.assetClasses = getAssetClassFilters(); this.assetClasses = getAssetClassFilters();
this.userService this.userService
@ -160,6 +167,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.hasExperimentalFeatures = settings.isExperimentalFeatures ?? false; this.hasExperimentalFeatures = settings.isExperimentalFeatures ?? false;
this.tags = getTagFilters(tags); this.tags = getTagFilters(tags);
this.dateAdapter.setLocale(settings.locale ?? DEFAULT_LOCALE);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

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

@ -62,6 +62,9 @@
/> />
</mat-datepicker-toggle> </mat-datepicker-toggle>
<mat-datepicker #expiresAt /> <mat-datepicker #expiresAt />
@if (accessForm.get('expiresAt')?.invalid) {
<mat-error i18n>Please select a date in the future.</mat-error>
}
</mat-form-field> </mat-form-field>
</div> </div>

5
libs/common/src/lib/dtos/create-access.dto.ts

@ -1,5 +1,6 @@
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { Scope, scopes } from '@ghostfolio/common/scopes'; import { Scope, scopes } from '@ghostfolio/common/scopes';
import { IsInTheFutureConstraint } from '@ghostfolio/common/validator-constraints/is-in-the-future';
import { AccessType } from '@prisma/client'; import { AccessType } from '@prisma/client';
import { import {
@ -9,7 +10,8 @@ import {
IsIn, IsIn,
IsOptional, IsOptional,
IsString, IsString,
IsUUID IsUUID,
Validate
} from 'class-validator'; } from 'class-validator';
export class CreateAccessDto { export class CreateAccessDto {
@ -18,6 +20,7 @@ export class CreateAccessDto {
alias?: string; alias?: string;
@IsDateString() @IsDateString()
@Validate(IsInTheFutureConstraint)
expiresAt: string; expiresAt: string;
@IsArray() @IsArray()

5
libs/common/src/lib/dtos/update-access.dto.ts

@ -1,5 +1,6 @@
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { Scope, scopes } from '@ghostfolio/common/scopes'; import { Scope, scopes } from '@ghostfolio/common/scopes';
import { IsInTheFutureConstraint } from '@ghostfolio/common/validator-constraints/is-in-the-future';
import { import {
IsArray, IsArray,
@ -7,7 +8,8 @@ import {
IsIn, IsIn,
IsOptional, IsOptional,
IsString, IsString,
IsUUID IsUUID,
Validate
} from 'class-validator'; } from 'class-validator';
export class UpdateAccessDto { export class UpdateAccessDto {
@ -16,6 +18,7 @@ export class UpdateAccessDto {
alias?: string; alias?: string;
@IsDateString() @IsDateString()
@Validate(IsInTheFutureConstraint)
expiresAt: string; expiresAt: string;
@IsArray() @IsArray()

16
libs/common/src/lib/validator-constraints/is-in-the-future.ts

@ -0,0 +1,16 @@
import {
ValidatorConstraint,
ValidatorConstraintInterface
} from 'class-validator';
import { isFuture } from 'date-fns';
@ValidatorConstraint({ name: 'isInTheFuture' })
export class IsInTheFutureConstraint implements ValidatorConstraintInterface {
public defaultMessage() {
return '$property must be a date in the future';
}
public validate(aDate: Date) {
return isFuture(aDate);
}
}
Loading…
Cancel
Save