Browse Source

Add expiration date to access

pull/7709/head
Thomas Kaul 3 days ago
parent
commit
1454ac3d80
  1. 4
      apps/api/src/app/access/access.service.ts
  2. 2
      apps/api/src/app/user/user.service.ts
  3. 34
      apps/api/src/services/impersonation/impersonation.service.spec.ts
  4. 4
      apps/client/src/app/components/access-table/access-table.component.html
  5. 7
      apps/client/src/app/components/access-table/access-table.component.ts
  6. 4
      apps/client/src/app/components/header/header.component.html
  7. 8
      apps/client/src/app/components/header/header.component.ts
  8. 13
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  9. 3
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  10. 5
      libs/common/src/lib/dtos/create-access.dto.ts
  11. 5
      libs/common/src/lib/dtos/update-access.dto.ts
  12. 16
      libs/common/src/lib/validator-constraints/is-in-the-future.ts

4
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<Access, 'expiresAt'>) {
return isBefore(expiresAt, new Date());
return isBefore(expiresAt, new Date()) || !isValid(expiresAt);
}
public async updateAccess({

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

34
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();

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

@ -31,7 +31,7 @@
<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 }}
{{ element.lastUsedAt | date: defaultDateFormat() }}
} @else {
<span></span>
}
@ -41,7 +41,7 @@
<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 }}
{{ element.expiresAt | date: defaultDateFormat() }}
</td>
</ng-container>

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

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

@ -203,7 +203,7 @@
></a>
<hr class="m-0" />
}
@if (accesses()?.length > 0) {
@if (user()?.access?.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 accesses(); track accessItem.id) {
@for (accessItem of user()?.access; 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,7 +26,6 @@ import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
computed,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
HostListener,
@ -44,7 +43,6 @@ 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 {
@ -95,12 +93,6 @@ export class GfHeaderComponent implements OnChanges {
protected readonly assistentMenuTriggerElement =
viewChild.required<MatMenuTrigger>('assistantTrigger');
protected readonly accesses = computed(() => {
return this.user()?.access?.filter(({ expiresAt }) => {
return !isBefore(expiresAt, new Date());
});
});
protected hasFilters: boolean;
protected hasImpersonationId: boolean;
protected hasPermissionForAuthGoogle: boolean;

13
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,7 @@ 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,
@ -50,7 +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, startOfDay } from 'date-fns';
import { addYears, endOfDay, isBefore, startOfDay } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import { EMPTY, catchError } from 'rxjs';
@ -64,8 +66,8 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
GfAccessLevelIconComponent,
GfPortfolioFilterFormComponent,
MatButtonModule,
MatDialogModule,
MatDatepickerModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
@ -93,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);
@ -150,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
@ -160,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();
});

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

@ -62,6 +62,9 @@
/>
</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>

5
libs/common/src/lib/dtos/create-access.dto.ts

@ -1,5 +1,6 @@
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 {
@ -9,7 +10,8 @@ import {
IsIn,
IsOptional,
IsString,
IsUUID
IsUUID,
Validate
} from 'class-validator';
export class CreateAccessDto {
@ -18,6 +20,7 @@ export class CreateAccessDto {
alias?: string;
@IsDateString()
@Validate(IsInTheFutureConstraint)
expiresAt: string;
@IsArray()

5
libs/common/src/lib/dtos/update-access.dto.ts

@ -1,5 +1,6 @@
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,
@ -7,7 +8,8 @@ import {
IsIn,
IsOptional,
IsString,
IsUUID
IsUUID,
Validate
} from 'class-validator';
export class UpdateAccessDto {
@ -16,6 +18,7 @@ export class UpdateAccessDto {
alias?: string;
@IsDateString()
@Validate(IsInTheFutureConstraint)
expiresAt: string;
@IsArray()

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);
}
}
Loading…
Cancel
Save