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/user/user.service.ts b/apps/api/src/app/user/user.service.ts index c8220a824..082aeb779 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -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.service.spec.ts b/apps/api/src/services/impersonation/impersonation.service.spec.ts index b333f8270..60c5d3492 100644 --- a/apps/api/src/services/impersonation/impersonation.service.spec.ts +++ b/apps/api/src/services/impersonation/impersonation.service.spec.ts @@ -12,6 +12,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 +41,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 +82,8 @@ describe('Impersonation service', () => { } return access; - } + }, + update: updateAccess }, user: { findUnique: async () => { @@ -94,6 +98,7 @@ describe('Impersonation service', () => { return { getSubscription, + updateAccess, service: new ImpersonationService( configurationService, prismaService, @@ -361,6 +366,143 @@ 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, + 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 = { + 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(); + }); + }); + describe('With an identifier which cannot be resolved', () => { it('Resolves the own access instead', async () => { const { service } = createService(); diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts index 5dfb5b0f6..3c27f907e 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -16,6 +16,7 @@ import type { import { Injectable } from '@nestjs/common'; import { Access, AccessType } from '@prisma/client'; +import { isBefore, isToday } from 'date-fns'; @Injectable() export class ImpersonationService { @@ -112,9 +113,12 @@ export class ImpersonationService { } }); - if (accessObject?.userId) { + if (accessObject?.userId && !isExpired(accessObject)) { + await this.recordUsage(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,11 +137,37 @@ export class ImpersonationService { } }); - if (accessObject?.userId) { + if (accessObject?.userId && !isExpired(accessObject)) { + await this.recordUsage(accessObject); + return { access: accessObject, userId: accessObject.userId }; } } return { userId: null }; } + + /** + * Records that the access has been used. The value is the first usage of the + * day, because a request which repeats must not write to the database again. + */ + private async recordUsage({ id, lastUsedAt }: Access) { + 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 + } + } +} + +function isExpired({ expiresAt }: Access) { + return expiresAt ? isBefore(expiresAt, new Date()) : false; } 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..e5afbd3fe 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..fa43a77e0 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, getLocale } 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', + 'details', + 'lastUsedAt', + 'expiresAt' + ]; if (this.showActions()) { columns.push('actions'); @@ -87,6 +97,7 @@ export class GfAccessTableComponent { return columns; }); + protected readonly defaultDateFormat = getDateFormatString(getLocale()); 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..d7d921cdb 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,5 +1,6 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; +import { getToday } from '@ghostfolio/common/helper'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { @@ -41,6 +42,7 @@ import { Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -49,6 +51,7 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; +import { addYears, endOfDay } from 'date-fns'; import { StatusCodes } from 'http-status-codes'; import { EMPTY, catchError } from 'rxjs'; @@ -63,6 +66,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; GfPortfolioFilterFormComponent, MatButtonModule, MatDialogModule, + MatDatepickerModule, MatFormFieldModule, MatInputModule, MatSelectModule, @@ -80,6 +84,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { protected accessForm: FormGroup; protected readonly mode: 'create' | 'update'; + protected readonly today = getToday(); private hasExperimentalFeatures = false; private hasPermissionToEnableMcp = false; @@ -129,6 +134,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, @@ -203,6 +214,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 +246,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 +306,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..3aabec69f 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,26 @@ +
+ + Expiration Date + + + + + + +
+
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..b21621ac4 100644 --- a/libs/common/src/lib/dtos/create-access.dto.ts +++ b/libs/common/src/lib/dtos/create-access.dto.ts @@ -4,6 +4,7 @@ import { Scope, scopes } from '@ghostfolio/common/scopes'; import { AccessType } from '@prisma/client'; import { IsArray, + IsDateString, IsEnum, IsIn, IsOptional, @@ -16,6 +17,9 @@ export class CreateAccessDto { @IsString() alias?: string; + @IsDateString() + 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..54e8c1596 100644 --- a/libs/common/src/lib/dtos/update-access.dto.ts +++ b/libs/common/src/lib/dtos/update-access.dto.ts @@ -1,13 +1,23 @@ import { Filter } from '@ghostfolio/common/interfaces'; import { Scope, scopes } from '@ghostfolio/common/scopes'; -import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; +import { + IsArray, + IsDateString, + IsIn, + IsOptional, + IsString, + IsUUID +} from 'class-validator'; export class UpdateAccessDto { @IsOptional() @IsString() alias?: string; + @IsDateString() + 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..0cd548f98 100644 --- a/libs/common/src/lib/interfaces/access.interface.ts +++ b/libs/common/src/lib/interfaces/access.interface.ts @@ -4,6 +4,8 @@ import { AccessSettings } from './access-settings.interface'; export interface Access { alias: string | null; + expiresAt: Date; + lastUsedAt?: Date | null; grantee?: string; id: string; scopes: string[]; 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/prisma/migrations/20260823140000_added_expires_at_and_last_used_at_to_access/migration.sql b/prisma/migrations/20260823140000_added_expires_at_and_last_used_at_to_access/migration.sql new file mode 100644 index 000000000..405fe4bdf --- /dev/null +++ b/prisma/migrations/20260823140000_added_expires_at_and_last_used_at_to_access/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Access" ADD COLUMN "expiresAt" TIMESTAMP(3), +ADD COLUMN "lastUsedAt" 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/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)