Browse Source

Task/add expiration date to access (#7709)

* Add expiration date to access

* Update changelog
pull/7664/head
Thomas Kaul 2 days ago
committed by GitHub
parent
commit
b99e8a0c12
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      CHANGELOG.md
  2. 7
      apps/api/src/app/access/access.controller.ts
  3. 29
      apps/api/src/app/access/access.service.ts
  4. 4
      apps/api/src/app/endpoints/public/public.service.ts
  5. 4
      apps/api/src/app/user/user.service.ts
  6. 8
      apps/api/src/services/impersonation/impersonation.module.ts
  7. 176
      apps/api/src/services/impersonation/impersonation.service.spec.ts
  8. 11
      apps/api/src/services/impersonation/impersonation.service.ts
  9. 18
      apps/client/src/app/components/access-table/access-table.component.html
  10. 16
      apps/client/src/app/components/access-table/access-table.component.ts
  11. 27
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  12. 23
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  13. 8
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  14. 9
      libs/common/src/lib/dtos/create-access.dto.ts
  15. 15
      libs/common/src/lib/dtos/update-access.dto.ts
  16. 2
      libs/common/src/lib/interfaces/access.interface.ts
  17. 5
      libs/common/src/lib/interfaces/user.interface.ts
  18. 16
      libs/common/src/lib/validator-constraints/is-in-the-future.ts
  19. 11
      prisma/migrations/20260826000000_added_expires_at_to_access/migration.sql
  20. 2
      prisma/migrations/20260826010000_added_last_used_at_to_access/migration.sql
  21. 2
      prisma/schema.prisma

7
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

7
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 },

29
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<Access, 'expiresAt'>) {
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<Access, 'id' | 'lastUsedAt'>) {
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
}
}
}

4
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({

4
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)
};
}),

8
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]
})

176
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', () => {

11
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 };
}
}

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

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

16
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<Access>();
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;

27
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<CreateOrUpdateAccessDialogParams>(MAT_DIALOG_DATA);
private readonly dateAdapter = inject<DateAdapter<Date, string>>(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,

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

@ -45,6 +45,29 @@
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Expiration Date</mat-label>
<input
formControlName="expiresAt"
matInput
[matDatepicker]="expiresAt"
[min]="today"
/>
<mat-datepicker-toggle class="mr-2" matSuffix [for]="expiresAt">
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
/>
</mat-datepicker-toggle>
<mat-datepicker #expiresAt />
@if (accessForm.get('expiresAt')?.invalid) {
<mat-error i18n>Please select a date in the future.</mat-error>
}
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Permission</mat-label>

8
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 }) => {
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()

9
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[];

15
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[];

2
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;

5
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, 'alias' | 'id' | 'scopes'>[];
access: Pick<
Access,
'alias' | 'expiresAt' | 'id' | 'lastUsedAt' | 'scopes'
>[];
accounts: AccountWithPlatform[];
activitiesCount: number;
activityTypes: ActivityType[];

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);
}
}

11
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;

2
prisma/migrations/20260826010000_added_last_used_at_to_access/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Access" ADD COLUMN "lastUsedAt" TIMESTAMP(3);

2
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)

Loading…
Cancel
Save