From 1454ac3d803d09db8d4bd18df6f08200c3fcf1c2 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:32:47 +0200 Subject: [PATCH] Add expiration date to access --- apps/api/src/app/access/access.service.ts | 4 +-- apps/api/src/app/user/user.service.ts | 2 +- .../impersonation.service.spec.ts | 34 +++++++++++++++++-- .../access-table/access-table.component.html | 4 +-- .../access-table/access-table.component.ts | 7 ++-- .../components/header/header.component.html | 4 +-- .../app/components/header/header.component.ts | 8 ----- ...reate-or-update-access-dialog.component.ts | 13 +++++-- .../create-or-update-access-dialog.html | 3 ++ libs/common/src/lib/dtos/create-access.dto.ts | 5 ++- libs/common/src/lib/dtos/update-access.dto.ts | 5 ++- .../validator-constraints/is-in-the-future.ts | 16 +++++++++ 12 files changed, 82 insertions(+), 23 deletions(-) create mode 100644 libs/common/src/lib/validator-constraints/is-in-the-future.ts diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index 1572cda03..40dd467c9 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -4,7 +4,7 @@ import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { Access, Prisma } from '@prisma/client'; -import { isBefore, isToday } from 'date-fns'; +import { isBefore, isToday, isValid } from 'date-fns'; @Injectable() export class AccessService { @@ -62,7 +62,7 @@ export class AccessService { } public isExpired({ expiresAt }: Pick) { - return isBefore(expiresAt, new Date()); + return isBefore(expiresAt, new Date()) || !isValid(expiresAt); } public async updateAccess({ diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 082aeb779..ac997a874 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -138,7 +138,7 @@ export class UserService { user: true }, orderBy: { alias: 'asc' }, - where: { granteeUserId: id } + where: { expiresAt: { gt: new Date() }, granteeUserId: id } }), this.prismaService.account.findMany({ include: { platform: true }, diff --git a/apps/api/src/services/impersonation/impersonation.service.spec.ts b/apps/api/src/services/impersonation/impersonation.service.spec.ts index 3cfb90b4f..ab8d5fe44 100644 --- a/apps/api/src/services/impersonation/impersonation.service.spec.ts +++ b/apps/api/src/services/impersonation/impersonation.service.spec.ts @@ -136,6 +136,7 @@ describe('Impersonation service', () => { describe('With an impersonation', () => { const grantedAccess = { + expiresAt: addDays(new Date(), 1), granteeUserId: authenticatedUserId, id: accessId, scopes: [scopes.portfolioRead], @@ -265,6 +266,7 @@ describe('Impersonation service', () => { // the access itself is the credential describe('With an access as the credential', () => { const accessOfMcp = { + expiresAt: addDays(new Date(), 1), granteeUserId: null, id: accessId, scopes: [scopes.portfolioRead], @@ -337,6 +339,33 @@ describe('Impersonation service', () => { 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 // granted it, hence an access can never be resolved by another identifier 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', () => { const expiringAccess = { granteeUserId: authenticatedUserId, @@ -451,6 +478,7 @@ describe('Impersonation service', () => { }; const usedAccess = { + expiresAt: addDays(new Date(), 1), granteeUserId: authenticatedUserId, id: accessId, 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', () => { it('Resolves the own access instead', async () => { const { service } = createService(); diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index e5afbd3fe..fc324fb87 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -31,7 +31,7 @@ Last Used @if (element.lastUsedAt) { - {{ element.lastUsedAt | date: defaultDateFormat }} + {{ element.lastUsedAt | date: defaultDateFormat() }} } @else { } @@ -41,7 +41,7 @@ Expiration - {{ element.expiresAt | date: defaultDateFormat }} + {{ element.expiresAt | date: defaultDateFormat() }} diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index 72c1066ac..baf4247e3 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -1,6 +1,6 @@ import { MCP_ENDPOINT } from '@ghostfolio/common/config'; 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 { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; @@ -97,7 +97,10 @@ export class GfAccessTableComponent { return columns; }); - protected readonly defaultDateFormat = getDateFormatString(getLocale()); + protected readonly defaultDateFormat = computed(() => { + return getDateFormatString(this.user()?.settings?.locale); + }); + protected readonly getAccessLevel = getAccessLevel; protected hasPermissionToEnableMcp = false; diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 0806765f7..eaccbb332 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -203,7 +203,7 @@ >
} - @if (accesses()?.length > 0) { + @if (user()?.access?.length > 0) { - @for (accessItem of accesses(); track accessItem.id) { + @for (accessItem of user()?.access; track accessItem.id) {