Browse Source

Add expiration date to access

pull/7709/head
Thomas Kaul 3 days ago
parent
commit
72106ad74d
  1. 29
      apps/api/src/app/access/access.service.ts
  2. 4
      apps/api/src/app/endpoints/public/public.service.ts
  3. 8
      apps/api/src/services/impersonation/impersonation.module.ts
  4. 2
      apps/api/src/services/impersonation/impersonation.service.spec.ts
  5. 35
      apps/api/src/services/impersonation/impersonation.service.ts
  6. 4
      apps/client/src/app/components/access-table/access-table.component.ts
  7. 4
      apps/client/src/app/components/header/header.component.html
  8. 8
      apps/client/src/app/components/header/header.component.ts
  9. 5
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  10. 2
      libs/common/src/lib/interfaces/access.interface.ts

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 } from 'date-fns';
@Injectable()
export class AccessService {
@ -60,6 +61,10 @@ export class AccessService {
});
}
public isExpired({ expiresAt }: Pick<Access, 'expiresAt'>) {
return expiresAt ? isBefore(expiresAt, new Date()) : false;
}
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({

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

2
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';
@ -100,6 +101,7 @@ describe('Impersonation service', () => {
getSubscription,
updateAccess,
service: new ImpersonationService(
new AccessService(prismaService),
configurationService,
prismaService,
subscriptionService

35
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';
@ -16,11 +17,11 @@ import type {
import { Injectable } from '@nestjs/common';
import { Access, AccessType } from '@prisma/client';
import { isBefore, isToday } from 'date-fns';
@Injectable()
export class ImpersonationService {
public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
@ -113,8 +114,8 @@ export class ImpersonationService {
}
});
if (accessObject?.userId && !isExpired(accessObject)) {
await this.recordUsage(accessObject);
if (accessObject?.userId && !this.accessService.isExpired(accessObject)) {
await this.accessService.updateLastUsedAt(accessObject);
return { access: accessObject, userId: accessObject.userId };
} else if (
@ -137,8 +138,8 @@ export class ImpersonationService {
}
});
if (accessObject?.userId && !isExpired(accessObject)) {
await this.recordUsage(accessObject);
if (accessObject?.userId && !this.accessService.isExpired(accessObject)) {
await this.accessService.updateLastUsedAt(accessObject);
return { access: accessObject, userId: accessObject.userId };
}
@ -146,28 +147,4 @@ export class ImpersonationService {
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;
}

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

@ -85,9 +85,9 @@ export class GfAccessTableComponent {
'alias',
'grantee',
'type',
'details',
'lastUsedAt',
'expiresAt'
'expiresAt',
'details'
];
if (this.showActions()) {

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

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

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

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

5
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -1,6 +1,5 @@
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 {
@ -51,7 +50,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 { addYears, endOfDay, startOfDay } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import { EMPTY, catchError } from 'rxjs';
@ -84,7 +83,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
protected accessForm: FormGroup;
protected readonly mode: 'create' | 'update';
protected readonly today = getToday();
protected readonly today = startOfDay(new Date());
private hasExperimentalFeatures = false;
private hasPermissionToEnableMcp = false;

2
libs/common/src/lib/interfaces/access.interface.ts

@ -5,9 +5,9 @@ import { AccessSettings } from './access-settings.interface';
export interface Access {
alias: string | null;
expiresAt: Date;
lastUsedAt?: Date | null;
grantee?: string;
id: string;
lastUsedAt?: Date | null;
scopes: string[];
settings?: AccessSettings;
type: AccessType;

Loading…
Cancel
Save