From fe9b0654ee6dca9e456d249a7d369f464251cfcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 21 Apr 2026 20:48:42 +0200 Subject: [PATCH 1/8] Add new endpoint --- .../ApiKeys/SetApiKeyExpiryDateEndpoint.cs | 65 +++++++++++++++++++ .../ApiKeys/SetApiKeyStatusEndpoint.cs | 5 -- src/Turnierplan.Core/ApiKey/ApiKey.cs | 2 +- 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs new file mode 100644 index 00000000..a33a94bd --- /dev/null +++ b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs @@ -0,0 +1,65 @@ +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(); + } + + apiKey.ExpiryDate = DateTime.UtcNow.AddDays(request.Validity); + + 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.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs index 58d27beb..4eac50a4 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs @@ -32,11 +32,6 @@ private static async Task Handle( return Results.Forbid(); } - if (apiKey.IsExpired) - { - return Results.BadRequest("API key is already expired."); - } - if (apiKey.IsActive == request.IsActive) { return Results.NoContent(); diff --git a/src/Turnierplan.Core/ApiKey/ApiKey.cs b/src/Turnierplan.Core/ApiKey/ApiKey.cs index 76981cd2..08b477fc 100644 --- a/src/Turnierplan.Core/ApiKey/ApiKey.cs +++ b/src/Turnierplan.Core/ApiKey/ApiKey.cs @@ -55,7 +55,7 @@ internal ApiKey(long id, Guid principalId, PublicId.PublicId publicId, string na public DateTime CreatedAt { get; } - public DateTime ExpiryDate { get; } + public DateTime ExpiryDate { get; set; } public bool IsExpired => DateTime.UtcNow >= ExpiryDate; From 5f1a2d453a3203e1034cac3964552f723979d44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 21 Apr 2026 21:13:37 +0200 Subject: [PATCH 2/8] Add new button in api key list --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 1 + .../view-organization.component.html | 11 +++++++++-- .../view-organization/view-organization.component.ts | 4 +++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index d507cac4..2deaf986 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -251,6 +251,7 @@ export const de = { Expired: 'Dieser API-Schlüssel ist abgelaufen', NoApiKeys: 'Keine API-Schlüssel vorhanden', ViewCharts: 'Aufrufstatistik', + ExtendExpiryDate: 'Verlängern', 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.', 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..29783977 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) { - + + + Date: Thu, 23 Apr 2026 18:59:51 +0200 Subject: [PATCH 3/8] Add framework for new offcanvas --- .../api-key-extend.component.html | 1 + .../api-key-extend.component.ts | 21 ++++++++++ .../view-organization.component.html | 4 +- .../view-organization.component.ts | 42 +++++++++++++++++-- 4 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 src/Turnierplan.App/Client/src/app/portal/components/api-key-extend/api-key-extend.component.html create mode 100644 src/Turnierplan.App/Client/src/app/portal/components/api-key-extend/api-key-extend.component.ts 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..51b78f54 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/api-key-extend/api-key-extend.component.html @@ -0,0 +1 @@ +

api-key-extend works!

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..9d2843a4 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/api-key-extend/api-key-extend.component.ts @@ -0,0 +1,21 @@ +import { Component, OnDestroy } from '@angular/core'; +import { ApiKeyDto } from '../../../api/models/api-key-dto'; +import { Observable, Subject } from 'rxjs'; + +@Component({ + imports: [], + templateUrl: './api-key-extend.component.html' +}) +export class ApiKeyExtendComponent implements OnDestroy { + private readonly errorSubject$ = new Subject(); + + public set apiKey(value: ApiKeyDto) {} + + public get error$(): Observable { + return this.errorSubject$.asObservable(); + } + + public ngOnDestroy(): void { + this.errorSubject$.complete(); + } +} 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 29783977..bf1efcd5 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 @@ -218,12 +218,12 @@ @if (writeAllowed) { - + [ngbTooltip]="'Portal.ViewOrganization.ApiKeys.ExtendExpiryDate' | translate" + (click)="showExtendApiKeyOffcanvas(apiKey)" /> (); constructor( @@ -148,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 @@ -293,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 }) From 4e1bb155cc695967b79349367c2b06d9df732220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 23 Apr 2026 19:26:09 +0200 Subject: [PATCH 4/8] Basic frontend implementation --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 11 ++++- .../api-key-extend.component.html | 45 ++++++++++++++++++- .../api-key-extend.component.ts | 44 +++++++++++++++++- .../create-api-key.component.html | 6 ++- .../create-api-key.component.ts | 14 +++++- .../view-organization.component.html | 2 +- 6 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 2deaf986..3b464fd6 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -251,7 +251,13 @@ export const de = { Expired: 'Dieser API-Schlüssel ist abgelaufen', NoApiKeys: 'Keine API-Schlüssel vorhanden', ViewCharts: 'Aufrufstatistik', - ExtendExpiryDate: 'Verlängern', + 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}}' + }, 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.', @@ -1278,7 +1284,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 index 51b78f54..9ad3df8e 100644 --- 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 @@ -1 +1,44 @@ -

api-key-extend works!

+
+
+ +
+
+ @if (isSubmitting) { + + } @else if (_apiKey) { +
+ + {{ _apiKey.name }} +
+ @if (_apiKey.description.length > 0) { +
{{ _apiKey.description }}
+ } +
+
+ + +
+ +
+ +
+ } +
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 index 9d2843a4..a5a38a97 100644 --- 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 @@ -1,15 +1,41 @@ 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'; @Component({ - imports: [], + imports: [ActionButtonComponent, TranslateDirective, LoadingIndicatorComponent, FormsModule, ReactiveFormsModule, TranslateDatePipe], templateUrl: './api-key-extend.component.html' }) export class ApiKeyExtendComponent implements OnDestroy { + protected isSubmitting = false; + protected validityControl = new FormControl(30, { nonNullable: true }); + protected _apiKey?: ApiKeyDto; + protected newValidUntil: Date = new Date(); + private readonly errorSubject$ = new Subject(); - public set apiKey(value: ApiKeyDto) {} + 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; + } public get error$(): Observable { return this.errorSubject$.asObservable(); @@ -18,4 +44,18 @@ export class ApiKeyExtendComponent implements OnDestroy { 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(Date.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 bf1efcd5..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 @@ -222,7 +222,7 @@ [icon]="'clock-history'" [type]="'outline-secondary'" [mode]="'IconOnly'" - [ngbTooltip]="'Portal.ViewOrganization.ApiKeys.ExtendExpiryDate' | translate" + [ngbTooltip]="'Portal.ViewOrganization.ApiKeys.Extend.Tooltip' | translate" (click)="showExtendApiKeyOffcanvas(apiKey)" /> Date: Thu, 23 Apr 2026 19:31:52 +0200 Subject: [PATCH 5/8] Prevent setting sooner expiry in backend --- .../ApiKeys/SetApiKeyExpiryDateEndpoint.cs | 2 +- .../ApiKey/ApiKeyTest.cs | 27 +++++++++++++++++++ src/Turnierplan.Core/ApiKey/ApiKey.cs | 13 ++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/Turnierplan.Core.Test.Unit/ApiKey/ApiKeyTest.cs diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs index a33a94bd..cd530283 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs @@ -39,7 +39,7 @@ private static async Task Handle( return Results.Forbid(); } - apiKey.ExpiryDate = DateTime.UtcNow.AddDays(request.Validity); + apiKey.SetExpiryDate(DateTime.UtcNow.AddDays(request.Validity)); await repository.UnitOfWork.SaveChangesAsync(cancellationToken); 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 08b477fc..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; set; } + 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); From 48f68a6f82edb42d82767f2f0871821be1834064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 23 Apr 2026 19:41:18 +0200 Subject: [PATCH 6/8] Adjust frontend --- src/Turnierplan.App/Client/src/app/i18n/de.ts | 3 +- .../api-key-extend.component.html | 45 ++++++++++--------- .../api-key-extend.component.ts | 24 +++++++++- .../ApiKeys/SetApiKeyExpiryDateEndpoint.cs | 9 +++- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 3b464fd6..954eacee 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -256,7 +256,8 @@ export const de = { 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}}' + ValidUntil: 'Gültig bis {{date}}', + NotPossible: 'Dieser API-Schlüssel kann aktuell noch nicht verlängert werden.' }, Delete: { Title: 'API-Schlüssel löschen', 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 index 9ad3df8e..56b1c3b0 100644 --- 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 @@ -18,27 +18,28 @@
{{ _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 index a5a38a97..2100e1e6 100644 --- 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 @@ -10,18 +10,29 @@ 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], + 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, @@ -35,6 +46,15 @@ export class ApiKeyExtendComponent implements OnDestroy { 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 { @@ -56,6 +76,6 @@ export class ApiKeyExtendComponent implements OnDestroy { } private calculateNewValidUntil(): void { - this.newValidUntil = new Date(Date.now() + this.validityControl.value * 86400000); + this.newValidUntil = new Date(this.now + this.validityControl.value * 86400000); } } diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs index cd530283..41139452 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs @@ -39,7 +39,14 @@ private static async Task Handle( return Results.Forbid(); } - apiKey.SetExpiryDate(DateTime.UtcNow.AddDays(request.Validity)); + var newExpiry = DateTime.UtcNow.AddDays(request.Validity); + + if (newExpiry < apiKey.ExpiryDate) + { + return Results.BadRequest("New expiry date must be after the current expiry date."); + } + + apiKey.SetExpiryDate(newExpiry); await repository.UnitOfWork.SaveChangesAsync(cancellationToken); From cde7f98a1c1b1d04a14bd2adba6a61b7205af087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 23 Apr 2026 20:33:03 +0200 Subject: [PATCH 7/8] Revert --- .../Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs index 4eac50a4..58d27beb 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyStatusEndpoint.cs @@ -32,6 +32,11 @@ private static async Task Handle( return Results.Forbid(); } + if (apiKey.IsExpired) + { + return Results.BadRequest("API key is already expired."); + } + if (apiKey.IsActive == request.IsActive) { return Results.NoContent(); From c77c7a80f2bd9c181756b08b52fff667425b9476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 23 Apr 2026 21:34:50 +0200 Subject: [PATCH 8/8] Rename --- .../Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs index 41139452..aaa3a524 100644 --- a/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs +++ b/src/Turnierplan.App/Endpoints/ApiKeys/SetApiKeyExpiryDateEndpoint.cs @@ -39,14 +39,14 @@ private static async Task Handle( return Results.Forbid(); } - var newExpiry = DateTime.UtcNow.AddDays(request.Validity); + var newExpiryDate = DateTime.UtcNow.AddDays(request.Validity); - if (newExpiry < apiKey.ExpiryDate) + if (newExpiryDate < apiKey.ExpiryDate) { return Results.BadRequest("New expiry date must be after the current expiry date."); } - apiKey.SetExpiryDate(newExpiry); + apiKey.SetExpiryDate(newExpiryDate); await repository.UnitOfWork.SaveChangesAsync(cancellationToken);