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) {
+
+
+
+ @for (option of this.validityOptions; track option) {
+
+ }
+
+
+
+
+
+
+ } @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);