From b99e8a0c126e242b02f8f00215d5c3c2a1814f2f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:04:57 +0200 Subject: [PATCH] Task/add expiration date to access (#7709) * Add expiration date to access * Update changelog --- CHANGELOG.md | 7 + apps/api/src/app/access/access.controller.ts | 7 +- apps/api/src/app/access/access.service.ts | 29 +++ .../app/endpoints/public/public.service.ts | 4 +- apps/api/src/app/user/user.service.ts | 4 +- .../impersonation/impersonation.module.ts | 8 +- .../impersonation.service.spec.ts | 176 +++++++++++++++++- .../impersonation/impersonation.service.ts | 11 +- .../access-table/access-table.component.html | 18 ++ .../access-table/access-table.component.ts | 16 +- ...reate-or-update-access-dialog.component.ts | 27 +++ .../create-or-update-access-dialog.html | 23 +++ .../user-account-access.component.ts | 22 ++- libs/common/src/lib/dtos/create-access.dto.ts | 9 +- libs/common/src/lib/dtos/update-access.dto.ts | 15 +- .../src/lib/interfaces/access.interface.ts | 2 + .../src/lib/interfaces/user.interface.ts | 5 +- .../validator-constraints/is-in-the-future.ts | 16 ++ .../migration.sql | 11 ++ .../migration.sql | 2 + prisma/schema.prisma | 2 + 21 files changed, 394 insertions(+), 20 deletions(-) create mode 100644 libs/common/src/lib/validator-constraints/is-in-the-future.ts create mode 100644 prisma/migrations/20260826000000_added_expires_at_to_access/migration.sql create mode 100644 prisma/migrations/20260826010000_added_last_used_at_to_access/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index fe77cdbcf..b54041fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added an expiration date to the access to share the portfolio +- Added the date of the last usage to the access to share the portfolio + ## 3.61.0 - 2026-08-25 ### Changed diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 00e1fae98..eebe28a53 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -50,11 +50,14 @@ export class AccessController { }); return accessesWithGranteeUser.map((accessItem) => { - const { alias, granteeUser, id, settings, type } = accessItem; + const { alias, expiresAt, granteeUser, id, lastUsedAt, settings, type } = + accessItem; return { alias, + expiresAt, id, + lastUsedAt, type, grantee: granteeUser?.id, scopes: getScopesOfAccess(accessItem), @@ -105,6 +108,7 @@ export class AccessController { return await this.accessService.createAccess({ type, alias: data.alias || undefined, + expiresAt: new Date(data.expiresAt), granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : undefined, @@ -192,6 +196,7 @@ export class AccessController { return await this.accessService.updateAccess({ data: { alias: data.alias, + expiresAt: new Date(data.expiresAt), granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : { disconnect: true }, diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index e50a6c7d0..40dd467c9 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -4,6 +4,7 @@ import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { Access, Prisma } from '@prisma/client'; +import { isBefore, isToday, isValid } from 'date-fns'; @Injectable() export class AccessService { @@ -60,6 +61,10 @@ export class AccessService { }); } + public isExpired({ expiresAt }: Pick) { + return isBefore(expiresAt, new Date()) || !isValid(expiresAt); + } + public async updateAccess({ data, where @@ -72,4 +77,28 @@ export class AccessService { where }); } + + /** + * Stores the date of the last usage of the access. The value is the first + * usage of the day, because a request which repeats must not write to the + * database again. + */ + public async updateLastUsedAt({ + id, + lastUsedAt + }: Pick) { + if (lastUsedAt && isToday(lastUsedAt)) { + return; + } + + try { + await this.prismaService.access.update({ + data: { lastUsedAt: new Date() }, + where: { id } + }); + } catch { + // The date of the last usage is not essential for the request, hence a + // failure to store it must not fail the request + } + } } diff --git a/apps/api/src/app/endpoints/public/public.service.ts b/apps/api/src/app/endpoints/public/public.service.ts index 277cc0d8c..0b709cd01 100644 --- a/apps/api/src/app/endpoints/public/public.service.ts +++ b/apps/api/src/app/endpoints/public/public.service.ts @@ -40,13 +40,15 @@ export class PublicService { type: 'PUBLIC' }); - if (!access) { + if (!access || this.accessService.isExpired(access)) { throw new HttpException( getReasonPhrase(StatusCodes.NOT_FOUND), StatusCodes.NOT_FOUND ); } + await this.accessService.updateLastUsedAt(access); + let hasDetails = true; const user = await this.userService.user({ diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index c8220a824..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 }, @@ -230,7 +230,9 @@ export class UserService { access: access.map((accessItem) => { return { alias: accessItem.alias, + expiresAt: accessItem.expiresAt, id: accessItem.id, + lastUsedAt: accessItem.lastUsedAt, scopes: getScopesOfAccess(accessItem) }; }), diff --git a/apps/api/src/services/impersonation/impersonation.module.ts b/apps/api/src/services/impersonation/impersonation.module.ts index ff15af11a..b0e0567fa 100644 --- a/apps/api/src/services/impersonation/impersonation.module.ts +++ b/apps/api/src/services/impersonation/impersonation.module.ts @@ -1,3 +1,4 @@ +import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; @@ -6,7 +7,12 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { Module } from '@nestjs/common'; @Module({ - imports: [ConfigurationModule, PrismaModule, SubscriptionModule], + imports: [ + AccessModule, + ConfigurationModule, + PrismaModule, + SubscriptionModule + ], providers: [ImpersonationService], exports: [ImpersonationService] }) diff --git a/apps/api/src/services/impersonation/impersonation.service.spec.ts b/apps/api/src/services/impersonation/impersonation.service.spec.ts index b333f8270..ab8d5fe44 100644 --- a/apps/api/src/services/impersonation/impersonation.service.spec.ts +++ b/apps/api/src/services/impersonation/impersonation.service.spec.ts @@ -1,3 +1,4 @@ +import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -12,6 +13,7 @@ import { import type { UserWithSettings } from '@ghostfolio/common/types'; import { Access } from '@prisma/client'; +import { addDays, subDays } from 'date-fns'; import { ImpersonationService } from './impersonation.service'; @@ -40,6 +42,8 @@ describe('Impersonation service', () => { type: SubscriptionType.Basic }); + const updateAccess = jest.fn().mockResolvedValue(undefined); + const configurationService = { get: (key: string) => { return key === 'ENABLE_FEATURE_SUBSCRIPTION' @@ -79,7 +83,8 @@ describe('Impersonation service', () => { } return access; - } + }, + update: updateAccess }, user: { findUnique: async () => { @@ -94,7 +99,9 @@ describe('Impersonation service', () => { return { getSubscription, + updateAccess, service: new ImpersonationService( + new AccessService(prismaService), configurationService, prismaService, subscriptionService @@ -129,6 +136,7 @@ describe('Impersonation service', () => { describe('With an impersonation', () => { const grantedAccess = { + expiresAt: addDays(new Date(), 1), granteeUserId: authenticatedUserId, id: accessId, scopes: [scopes.portfolioRead], @@ -258,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], @@ -330,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 () => { @@ -359,6 +395,144 @@ describe('Impersonation service', () => { }); }); + describe('With an expiration date', () => { + const expiringAccess = { + granteeUserId: authenticatedUserId, + id: accessId, + scopes: [scopes.portfolioRead], + type: 'PRIVATE', + userId: impersonatedUserId + } as unknown as Access; + + const impersonatedUser = { + createdAt: new Date('2024-01-01'), + id: impersonatedUserId, + settings: { settings: { baseCurrency: 'USD' } }, + subscriptions: [] + }; + + it('Resolves an access which expires in the future', async () => { + const { service } = createService({ + impersonatedUser, + access: { + ...expiringAccess, + expiresAt: addDays(new Date(), 1) + } as unknown as Access + }); + + const { isActive } = await service.resolve({ + impersonationId: accessId, + user: authenticatedUser + }); + + expect(isActive).toEqual(true); + }); + + it('Refuses an access which has expired', async () => { + const { service } = createService({ + impersonatedUser, + access: { + ...expiringAccess, + expiresAt: subDays(new Date(), 1) + } as unknown as Access + }); + + const { isActive, scopes: scopesOfContext } = await service.resolve({ + impersonationId: accessId, + user: authenticatedUser + }); + + expect(isActive).toEqual(false); + expect(scopesOfContext).toEqual(getScopesOfOwnAccess()); + }); + + // An expired access must not fall through to the permission to impersonate + // all users, which would give an administrator the access again + it('Refuses an access which has expired for an administrator', async () => { + const { service } = createService({ + impersonatedUser, + access: { + ...expiringAccess, + expiresAt: subDays(new Date(), 1) + } as unknown as Access + }); + + const { isActive } = await service.resolve({ + impersonationId: accessId, + user: { + ...authenticatedUser, + permissions: [permissions.impersonateAllUsers] + } as unknown as typeof authenticatedUser + }); + + expect(isActive).toEqual(false); + }); + }); + + describe('With the date of the last usage', () => { + const impersonatedUser = { + createdAt: new Date('2024-01-01'), + id: impersonatedUserId, + settings: { settings: { baseCurrency: 'USD' } }, + subscriptions: [] + }; + + const usedAccess = { + expiresAt: addDays(new Date(), 1), + granteeUserId: authenticatedUserId, + id: accessId, + scopes: [scopes.portfolioRead], + type: 'PRIVATE', + userId: impersonatedUserId + } as unknown as Access; + + it('Records the first usage', async () => { + const { service, updateAccess } = createService({ + impersonatedUser, + access: usedAccess + }); + + await service.resolve({ + impersonationId: accessId, + user: authenticatedUser + }); + + expect(updateAccess).toHaveBeenCalledTimes(1); + }); + + it('Records the usage of a previous day', async () => { + const { service, updateAccess } = createService({ + impersonatedUser, + access: { + ...usedAccess, + lastUsedAt: subDays(new Date(), 1) + } as unknown as Access + }); + + await service.resolve({ + impersonationId: accessId, + user: authenticatedUser + }); + + expect(updateAccess).toHaveBeenCalledTimes(1); + }); + + // A request which repeats must not write to the database every time + it('Does not record a usage of the same day again', async () => { + const { service, updateAccess } = createService({ + impersonatedUser, + access: { ...usedAccess, lastUsedAt: new Date() } as unknown as Access + }); + + await service.resolve({ + impersonationId: accessId, + user: authenticatedUser + }); + + expect(updateAccess).not.toHaveBeenCalled(); + }); + }); + // 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', () => { diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts index 5dfb5b0f6..2a7915aa5 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -1,3 +1,4 @@ +import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -20,6 +21,7 @@ import { Access, AccessType } from '@prisma/client'; @Injectable() export class ImpersonationService { public constructor( + private readonly accessService: AccessService, private readonly configurationService: ConfigurationService, private readonly prismaService: PrismaService, private readonly subscriptionService: SubscriptionService @@ -112,9 +114,12 @@ export class ImpersonationService { } }); - if (accessObject?.userId) { + if (accessObject?.userId && !this.accessService.isExpired(accessObject)) { + await this.accessService.updateLastUsedAt(accessObject); + return { access: accessObject, userId: accessObject.userId }; } else if ( + !accessObject && hasPermission(user.permissions, permissions.impersonateAllUsers) ) { // The identifier is a user id in this case, hence verify its existence @@ -133,7 +138,9 @@ export class ImpersonationService { } }); - if (accessObject?.userId) { + if (accessObject?.userId && !this.accessService.isExpired(accessObject)) { + await this.accessService.updateLastUsedAt(accessObject); + return { access: accessObject, userId: accessObject.userId }; } } 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 15c0794ab..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 @@ -27,6 +27,24 @@ + + Last Used + + @if (element.lastUsedAt) { + {{ element.lastUsedAt | date: defaultDateFormat() }} + } @else { + + } + + + + + Expiration + + {{ element.expiresAt | date: defaultDateFormat() }} + + + Details 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 c73d088f8..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,5 +1,6 @@ import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; +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'; @@ -9,6 +10,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications'; import { DataService } from '@ghostfolio/ui/services'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -41,6 +43,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ClipboardModule, + CommonModule, GfAccessLevelIconComponent, IonIcon, MatButtonModule, @@ -78,7 +81,14 @@ export class GfAccessTableComponent { protected readonly dataSource = new MatTableDataSource(); protected readonly displayedColumns = computed(() => { - const columns = ['alias', 'grantee', 'type', 'details']; + const columns = [ + 'alias', + 'grantee', + 'type', + 'lastUsedAt', + 'expiresAt', + 'details' + ]; if (this.showActions()) { columns.push('actions'); @@ -87,6 +97,10 @@ export class GfAccessTableComponent { return columns; }); + 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/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index 4054de639..1757d20f8 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/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 { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -41,6 +42,8 @@ import { Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { DateAdapter } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -49,6 +52,7 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; +import { addYears, endOfDay, isBefore, startOfDay } from 'date-fns'; import { StatusCodes } from 'http-status-codes'; import { EMPTY, catchError } from 'rxjs'; @@ -62,6 +66,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; GfAccessLevelIconComponent, GfPortfolioFilterFormComponent, MatButtonModule, + MatDatepickerModule, MatDialogModule, MatFormFieldModule, MatInputModule, @@ -80,6 +85,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { protected accessForm: FormGroup; protected readonly mode: 'create' | 'update'; + protected readonly today = startOfDay(new Date()); private hasExperimentalFeatures = false; private hasPermissionToEnableMcp = false; @@ -89,6 +95,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { private readonly data = inject(MAT_DIALOG_DATA); + private readonly dateAdapter = inject>(DateAdapter); private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); @@ -129,6 +136,12 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.accessForm = this.formBuilder.group({ accessLevel: getAccessLevel(access?.scopes), alias: [access?.alias ?? ''], + expiresAt: [ + access?.expiresAt + ? new Date(access.expiresAt) + : addYears(this.today, 1), + Validators.required + ], filters: [null], granteeUserId: [ isPrivate ? (access?.grantee ?? null) : null, @@ -140,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.userService @@ -150,6 +167,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.hasExperimentalFeatures = settings.isExperimentalFeatures ?? false; this.tags = getTagFilters(tags); + this.dateAdapter.setLocale(settings.locale ?? DEFAULT_LOCALE); + this.changeDetectorRef.markForCheck(); }); @@ -203,6 +222,12 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } } + private buildExpiresAt() { + const expiresAt = this.accessForm.get('expiresAt')?.value as Date; + + return endOfDay(expiresAt).toISOString(); + } + private buildFilters(): Filter[] { return getFiltersFromPortfolioFilterFormValue( this.accessForm.get('filters')?.value @@ -229,6 +254,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { const access: CreateAccessDto = { alias: this.accessForm.get('alias')?.value, + expiresAt: this.buildExpiresAt(), filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, scopes: this.buildScopes(), @@ -288,6 +314,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { const access: UpdateAccessDto = { alias: this.accessForm.get('alias')?.value, + expiresAt: this.buildExpiresAt(), filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, id: accessId, diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index 131e4d782..ba5b9661e 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html @@ -45,6 +45,29 @@ +
+ + Expiration Date + + + + + + @if (accessForm.get('expiresAt')?.invalid) { + Please select a date in the future. + } + +
+
Permission diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index 4336cf752..0dc34c029 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -199,15 +199,19 @@ export class GfUserAccountAccessComponent { } private update() { - this.accessesGet = this.user.access.map(({ alias, id, scopes }) => { - return { - id, - scopes, - alias: alias ?? '', - grantee: $localize`Me`, - type: 'PRIVATE' - }; - }); + this.accessesGet = this.user.access.map( + ({ alias, expiresAt, id, lastUsedAt, scopes }) => { + return { + expiresAt, + id, + lastUsedAt, + scopes, + alias: alias ?? '', + grantee: $localize`Me`, + type: 'PRIVATE' + }; + } + ); this.dataService .fetchAccesses() diff --git a/libs/common/src/lib/dtos/create-access.dto.ts b/libs/common/src/lib/dtos/create-access.dto.ts index f8d479230..16d123429 100644 --- a/libs/common/src/lib/dtos/create-access.dto.ts +++ b/libs/common/src/lib/dtos/create-access.dto.ts @@ -1,14 +1,17 @@ import { Filter } from '@ghostfolio/common/interfaces'; import { Scope, scopes } from '@ghostfolio/common/scopes'; +import { IsInTheFutureConstraint } from '@ghostfolio/common/validator-constraints/is-in-the-future'; import { AccessType } from '@prisma/client'; import { IsArray, + IsDateString, IsEnum, IsIn, IsOptional, IsString, - IsUUID + IsUUID, + Validate } from 'class-validator'; export class CreateAccessDto { @@ -16,6 +19,10 @@ export class CreateAccessDto { @IsString() alias?: string; + @IsDateString() + @Validate(IsInTheFutureConstraint) + expiresAt: string; + @IsArray() @IsOptional() filters?: Filter[]; diff --git a/libs/common/src/lib/dtos/update-access.dto.ts b/libs/common/src/lib/dtos/update-access.dto.ts index c96294fc4..741423768 100644 --- a/libs/common/src/lib/dtos/update-access.dto.ts +++ b/libs/common/src/lib/dtos/update-access.dto.ts @@ -1,13 +1,26 @@ import { Filter } from '@ghostfolio/common/interfaces'; import { Scope, scopes } from '@ghostfolio/common/scopes'; +import { IsInTheFutureConstraint } from '@ghostfolio/common/validator-constraints/is-in-the-future'; -import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; +import { + IsArray, + IsDateString, + IsIn, + IsOptional, + IsString, + IsUUID, + Validate +} from 'class-validator'; export class UpdateAccessDto { @IsOptional() @IsString() alias?: string; + @IsDateString() + @Validate(IsInTheFutureConstraint) + expiresAt: string; + @IsArray() @IsOptional() filters?: Filter[]; diff --git a/libs/common/src/lib/interfaces/access.interface.ts b/libs/common/src/lib/interfaces/access.interface.ts index 1e543c86e..040bc2a8c 100644 --- a/libs/common/src/lib/interfaces/access.interface.ts +++ b/libs/common/src/lib/interfaces/access.interface.ts @@ -4,8 +4,10 @@ import { AccessSettings } from './access-settings.interface'; export interface Access { alias: string | null; + expiresAt: Date; grantee?: string; id: string; + lastUsedAt?: Date | null; scopes: string[]; settings?: AccessSettings; type: AccessType; diff --git a/libs/common/src/lib/interfaces/user.interface.ts b/libs/common/src/lib/interfaces/user.interface.ts index 901c5d29d..b6493c814 100644 --- a/libs/common/src/lib/interfaces/user.interface.ts +++ b/libs/common/src/lib/interfaces/user.interface.ts @@ -10,7 +10,10 @@ import { UserSettings } from './user-settings.interface'; // TODO: Compare with UserWithSettings export interface User { - access: Pick[]; + access: Pick< + Access, + 'alias' | 'expiresAt' | 'id' | 'lastUsedAt' | 'scopes' + >[]; accounts: AccountWithPlatform[]; activitiesCount: number; activityTypes: ActivityType[]; diff --git a/libs/common/src/lib/validator-constraints/is-in-the-future.ts b/libs/common/src/lib/validator-constraints/is-in-the-future.ts new file mode 100644 index 000000000..4d4c221e9 --- /dev/null +++ b/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); + } +} diff --git a/prisma/migrations/20260826000000_added_expires_at_to_access/migration.sql b/prisma/migrations/20260826000000_added_expires_at_to_access/migration.sql new file mode 100644 index 000000000..979d5b0f6 --- /dev/null +++ b/prisma/migrations/20260826000000_added_expires_at_to_access/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Access" ADD COLUMN "expiresAt" TIMESTAMP(3); + +-- Give an access which was granted before a date in the far future, because it +-- was granted with no expiration date +UPDATE "Access" +SET "expiresAt" = '2030-12-31 23:59:59.999' +WHERE "expiresAt" IS NULL; + +-- AlterTable +ALTER TABLE "Access" ALTER COLUMN "expiresAt" SET NOT NULL; diff --git a/prisma/migrations/20260826010000_added_last_used_at_to_access/migration.sql b/prisma/migrations/20260826010000_added_last_used_at_to_access/migration.sql new file mode 100644 index 000000000..25b7def99 --- /dev/null +++ b/prisma/migrations/20260826010000_added_last_used_at_to_access/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Access" ADD COLUMN "lastUsedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 600160124..7e4b15bfb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,9 +11,11 @@ datasource db { model Access { alias String? createdAt DateTime @default(now()) + expiresAt DateTime granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) granteeUserId String? id String @id @default(uuid()) + lastUsedAt DateTime? scopes String[] @default([]) settings Json @default("{}") type AccessType @default(PRIVATE)