diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index d507cac4..954eacee 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -251,6 +251,14 @@ export const de = { Expired: 'Dieser API-Schlüssel ist abgelaufen', NoApiKeys: 'Keine API-Schlüssel vorhanden', ViewCharts: 'Aufrufstatistik', + Extend: { + Tooltip: 'Verlängern', + Title: 'API-Schlüssel verlängern', + ValidityInfo: 'Nach dem Verlängern ist der API-Schlüssel ab sofort für den angegebenen Zeitraum gültig.', + Confirm: 'Verlängern', + ValidUntil: 'Gültig bis {{date}}', + NotPossible: 'Dieser API-Schlüssel kann aktuell noch nicht verlängert werden.' + }, Delete: { Title: 'API-Schlüssel löschen', Info: 'Wenn Sie eine API-Schlüssel löschen, kann dieser Schlüssel nicht mehr für neue Anfragen verwendet werden. Außerdem sind alle vorherigen Anfragen, welche mit diesem Schlüssel getätigt wurden, nicht mehr nachvollziehbar.', @@ -1277,7 +1285,8 @@ export const de = { Validity30: '30 Tage', Validity90: '90 Tage', Validity180: '180 Tage', - Validity365: '365 Tage' + Validity365: '365 Tage', + ValidUntil: 'Gültig bis {{date}}' }, OrganizationNotice: 'Es wird ein neuer API-Schlüssel in der Organisation {{organizationName}} angelegt.', AccessNotice: 'Ein neuer API-Schlüssel erhält standardmäßig Leserechte für die aktuelle Organisation.', diff --git a/src/Turnierplan.App/Client/src/app/portal/components/api-key-extend/api-key-extend.component.html b/src/Turnierplan.App/Client/src/app/portal/components/api-key-extend/api-key-extend.component.html new file mode 100644 index 00000000..56b1c3b0 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/api-key-extend/api-key-extend.component.html @@ -0,0 +1,45 @@ +
+
+ +
+
+ @if (isSubmitting) { + + } @else if (_apiKey) { +
+ + {{ _apiKey.name }} +
+ @if (_apiKey.description.length > 0) { +
{{ _apiKey.description }}
+ } +
+ @if (validityOptions.length > 0) { +
+ + +
+ +
+ +
+ } @else { + + } + } +
diff --git a/src/Turnierplan.App/Client/src/app/portal/components/api-key-extend/api-key-extend.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/api-key-extend/api-key-extend.component.ts new file mode 100644 index 00000000..2100e1e6 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/api-key-extend/api-key-extend.component.ts @@ -0,0 +1,81 @@ +import { Component, OnDestroy } from '@angular/core'; +import { ApiKeyDto } from '../../../api/models/api-key-dto'; +import { Observable, Subject } from 'rxjs'; +import { ActionButtonComponent } from '../action-button/action-button.component'; +import { NgbActiveOffcanvas } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateDirective } from '@ngx-translate/core'; +import { LoadingIndicatorComponent } from '../loading-indicator/loading-indicator.component'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { TranslateDatePipe } from '../../pipes/translate-date.pipe'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { TurnierplanApi } from '../../../api/turnierplan-api'; +import { setApiKeyExpiryDate } from '../../../api/fn/api-keys/set-api-key-expiry-date'; +import { AlertComponent } from '../alert/alert.component'; + +@Component({ + imports: [ + ActionButtonComponent, + TranslateDirective, + LoadingIndicatorComponent, + FormsModule, + ReactiveFormsModule, + TranslateDatePipe, + AlertComponent + ], + templateUrl: './api-key-extend.component.html' +}) +export class ApiKeyExtendComponent implements OnDestroy { + protected isSubmitting = false; + protected validityOptions: number[] = []; + protected validityControl = new FormControl(30, { nonNullable: true }); + protected _apiKey?: ApiKeyDto; + protected newValidUntil: Date = new Date(); + + private readonly errorSubject$ = new Subject(); + private readonly now = Date.now(); + + constructor( + protected readonly offcanvas: NgbActiveOffcanvas, + private readonly turnierplanApi: TurnierplanApi + ) { + this.calculateNewValidUntil(); + this.validityControl.valueChanges.pipe(takeUntilDestroyed()).subscribe({ + next: () => this.calculateNewValidUntil() + }); + } + + public set apiKey(value: ApiKeyDto) { + this._apiKey = value; + + // Add 1 hour worth of clock skew + const apiKeyExpireTime = new Date(value.expiryDate).getTime() + 60 * 60 * 1000; + + this.validityOptions = [1, 7, 30, 90, 180, 365].filter((x) => { + const validUntil = new Date(this.now + x * 86400000); + return validUntil.getTime() > apiKeyExpireTime; + }); + this.validityControl.setValue(this.validityOptions.length === 0 ? 0 : this.validityOptions[0]); + } + + public get error$(): Observable { + return this.errorSubject$.asObservable(); + } + + public ngOnDestroy(): void { + this.errorSubject$.complete(); + } + + protected confirmClicked(): void { + if (this._apiKey) { + this.isSubmitting = true; + this.turnierplanApi.invoke(setApiKeyExpiryDate, { id: this._apiKey.id, body: { validity: this.validityControl.value } }).subscribe({ + next: () => this.offcanvas.close(), + error: (error) => this.errorSubject$.next(error) + }); + } + } + + private calculateNewValidUntil(): void { + this.newValidUntil = new Date(this.now + this.validityControl.value * 86400000); + } +} diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.html index e865da26..6a84544d 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.html @@ -74,7 +74,7 @@
-
+
+

diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.ts index 2a88e234..cbcc45c8 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/create-api-key/create-api-key.component.ts @@ -18,6 +18,7 @@ import { TurnierplanApi } from '../../../api/turnierplan-api'; import { getOrganization } from '../../../api/fn/organizations/get-organization'; import { createApiKey } from '../../../api/fn/api-keys/create-api-key'; import { E2eDirective } from '../../../core/directives/e2e.directive'; +import { TranslateDatePipe } from '../../pipes/translate-date.pipe'; @Component({ templateUrl: './create-api-key.component.html', @@ -33,13 +34,15 @@ import { E2eDirective } from '../../../core/directives/e2e.directive'; ReactiveFormsModule, NgClass, TranslatePipe, - E2eDirective + E2eDirective, + TranslateDatePipe ] }) export class CreateApiKeyComponent { protected loadingState: LoadingState = { isLoading: false }; protected organization?: OrganizationDto; protected createdApiKey?: ApiKeyDto; + protected validUntil: Date = new Date(); protected form = new FormGroup({ name: new FormControl('', { nonNullable: true, validators: [Validators.required] }), @@ -75,6 +78,11 @@ export class CreateApiKeyComponent { this.loadingState = { isLoading: false, error: error }; } }); + + this.calculateValidUntil(); + this.form.controls.validity.valueChanges.pipe(takeUntilDestroyed()).subscribe({ + next: () => this.calculateValidUntil() + }); } protected get nameControl(): AbstractControl { @@ -106,4 +114,8 @@ export class CreateApiKeyComponent { }); } } + + private calculateValidUntil(): void { + this.validUntil = new Date(Date.now() + this.form.controls.validity.value * 86400000); + } } diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html index cbbe86bd..19625bf4 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-organization/view-organization.component.html @@ -212,13 +212,20 @@ @if (writeAllowed) { - + + (); constructor( @@ -146,8 +153,20 @@ export class ViewOrganizationComponent implements OnInit, OnDestroy { private readonly route: ActivatedRoute, private readonly titleService: TitleService, private readonly router: Router, - private readonly notificationService: NotificationService - ) {} + private readonly notificationService: NotificationService, + private readonly offcanvasService: NgbOffcanvas + ) { + this.router.events + .pipe( + takeUntilDestroyed(), + filter((event) => event instanceof NavigationStart) + ) + .subscribe({ + next: () => { + this.extendApiKeyOffcanvas?.close(); + } + }); + } public ngOnInit(): void { this.route.paramMap @@ -291,6 +310,23 @@ export class ViewOrganizationComponent implements OnInit, OnDestroy { }); } + protected showExtendApiKeyOffcanvas(apiKey: ApiKeyDto): void { + this.extendApiKeyOffcanvas = this.offcanvasService.open(ApiKeyExtendComponent, { position: 'end' }); + + const component = this.extendApiKeyOffcanvas.componentInstance as ApiKeyExtendComponent; + component.apiKey = apiKey; + + component.error$.subscribe({ + next: (value) => { + this.extendApiKeyOffcanvas?.close(); + this.loadingState = { isLoading: false, error: value }; + } + }); + + this.extendApiKeyOffcanvas.hidden.subscribe(() => (this.extendApiKeyOffcanvas = undefined)); + this.extendApiKeyOffcanvas.closed.pipe(switchMap(() => this.loadApiKeys())).subscribe(); + } + protected deleteApiKey(id: string): void { this.turnierplanApi .invoke(deleteApiKey, { id: id }) diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs new file mode 100644 index 00000000..aaa3a524 --- /dev/null +++ b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs @@ -0,0 +1,72 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Turnierplan.App.Extensions; +using Turnierplan.App.Security; +using Turnierplan.Core.PublicId; +using Turnierplan.Dal.Repositories; + +namespace Turnierplan.App.Endpoints.ApiKeys; + +internal sealed class SetApiKeyExpiryDateEndpoint : EndpointBase +{ + protected override HttpMethod Method => HttpMethod.Patch; + + protected override string Route => "/api/api-keys/{id}/expiry-date"; + + protected override Delegate Handler => Handle; + + private static async Task Handle( + [FromRoute] PublicId id, + [FromBody] SetApiKeyExpiryDateRequest request, + IApiKeyRepository repository, + IAccessValidator accessValidator, + CancellationToken cancellationToken) + { + if (!Validator.Instance.ValidateAndGetResult(request, out var result)) + { + return result; + } + + var apiKey = await repository.GetByPublicIdAsync(id); + + if (apiKey is null) + { + return Results.NotFound(); + } + + if (!accessValidator.IsActionAllowed(apiKey, Actions.GenericWrite)) + { + return Results.Forbid(); + } + + var newExpiryDate = DateTime.UtcNow.AddDays(request.Validity); + + if (newExpiryDate < apiKey.ExpiryDate) + { + return Results.BadRequest("New expiry date must be after the current expiry date."); + } + + apiKey.SetExpiryDate(newExpiryDate); + + await repository.UnitOfWork.SaveChangesAsync(cancellationToken); + + return Results.NoContent(); + } + + public sealed record SetApiKeyExpiryDateRequest + { + public required int Validity { get; init; } + } + + private sealed class Validator : AbstractValidator + { + public static readonly Validator Instance = new(); + + private Validator() + { + RuleFor(x => x.Validity) + .GreaterThanOrEqualTo(1) + .LessThanOrEqualTo(365); + } + } +} diff --git a/src/Turnierplan.Core.Test.Unit/ApiKey/ApiKeyTest.cs b/src/Turnierplan.Core.Test.Unit/ApiKey/ApiKeyTest.cs new file mode 100644 index 00000000..fff1b89e --- /dev/null +++ b/src/Turnierplan.Core.Test.Unit/ApiKey/ApiKeyTest.cs @@ -0,0 +1,27 @@ +using FluentAssertions; +using FluentAssertions.Extensions; +using Turnierplan.Core.Exceptions; +using Xunit; + +namespace Turnierplan.Core.Test.Unit.ApiKey; + +public sealed class ApiKeyTest +{ + [Fact] + public void SetExpiryDate___With_Valid_And_Invalid_Expiry_Date___Works_As_Expected() + { + var currentExpiry = DateTime.UtcNow.AddDays(18); + var organization = new Organization.Organization("Test"); + var apiKey = new Core.ApiKey.ApiKey(organization, string.Empty, string.Empty, currentExpiry); + + apiKey.ExpiryDate.Should().Be(currentExpiry); + + var expiry2 = currentExpiry.AddDays(7); + apiKey.SetExpiryDate(expiry2); + apiKey.ExpiryDate.Should().Be(expiry2); + + var expiry3 = currentExpiry.Subtract(1.Days()); + var action = () => apiKey.SetExpiryDate(expiry3); + action.Should().ThrowExactly().WithMessage("The new expiry date must be after the currently set expiry date."); + } +} diff --git a/src/Turnierplan.Core/ApiKey/ApiKey.cs b/src/Turnierplan.Core/ApiKey/ApiKey.cs index 76981cd2..190af384 100644 --- a/src/Turnierplan.Core/ApiKey/ApiKey.cs +++ b/src/Turnierplan.Core/ApiKey/ApiKey.cs @@ -1,4 +1,5 @@ using Turnierplan.Core.Entity; +using Turnierplan.Core.Exceptions; using Turnierplan.Core.RoleAssignment; namespace Turnierplan.Core.ApiKey; @@ -55,7 +56,7 @@ internal ApiKey(long id, Guid principalId, PublicId.PublicId publicId, string na public DateTime CreatedAt { get; } - public DateTime ExpiryDate { get; } + public DateTime ExpiryDate { get; private set; } public bool IsExpired => DateTime.UtcNow >= ExpiryDate; @@ -82,6 +83,16 @@ public void AssignNewSecret(Func secretHashFunc, out string plai SecretHash = secretHashFunc(plainTextSecret); } + public void SetExpiryDate(DateTime newExpiryDate) + { + if (newExpiryDate < ExpiryDate) + { + throw new TurnierplanException("The new expiry date must be after the currently set expiry date."); + } + + ExpiryDate = newExpiryDate; + } + public void AddRequest(ApiKeyRequest request) { _requests.Add(request);