Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/Turnierplan.App/Client/src/app/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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 <span class="fw-bold">{{organizationName}}</span> angelegt.',
AccessNotice: 'Ein neuer API-Schlüssel erhält standardmäßig Leserechte für die aktuelle Organisation.',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<div class="p-3">
<div class="mb-3">
<tp-action-button
[title]="'Portal.General.Cancel'"
[icon]="'x-circle'"
[type]="'outline-secondary'"
(buttonClick)="offcanvas.dismiss()" />
</div>
<div class="fs-4 fw-bold mb-3" translate="Portal.ViewOrganization.ApiKeys.Extend.Title"></div>
@if (isSubmitting) {
<tp-loading-indicator />
} @else if (_apiKey) {
<div class="mb-2 d-flex flex-row align-items-center">
<i class="bi bi-key me-2"></i>
<span class="fw-bold">{{ _apiKey.name }}</span>
</div>
@if (_apiKey.description.length > 0) {
<div class="mb-2 small">{{ _apiKey.description }}</div>
}
<hr />
@if (validityOptions.length > 0) {
<div class="mb-1">
<label for="create_apiKey_validity" class="form-label" translate="Portal.CreateApiKey.Form.Validity"></label>
<select id="create_apiKey_validity" class="form-select" [formControl]="validityControl">
@for (option of this.validityOptions; track option) {
<option value="{{ option }}" [translate]="'Portal.CreateApiKey.Form.Validity' + option"></option>
}
</select>
</div>
<span
class="small text-secondary"
[translate]="'Portal.ViewOrganization.ApiKeys.Extend.ValidUntil'"
[translateParams]="{ date: newValidUntil | translateDate: 'longDate' }"></span>
<div class="mt-3 d-flex flex-row align-items-center justify-content-end">
<tp-action-button
[title]="'Portal.ViewOrganization.ApiKeys.Extend.Confirm'"
[type]="'outline-success'"
[icon]="'check-circle'"
(buttonClick)="confirmClicked()" />
</div>
} @else {
<tp-alert [type]="'info'" [text]="'Portal.ViewOrganization.ApiKeys.Extend.NotPossible'" />
}
}
</div>
Original file line number Diff line number Diff line change
@@ -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<number>(30, { nonNullable: true });
protected _apiKey?: ApiKeyDto;
protected newValidUntil: Date = new Date();

private readonly errorSubject$ = new Subject<unknown>();
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<unknown> {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
<div class="valid-feedback" translate="Portal.CreateApiKey.Form.DescriptionValid"></div>
</div>

<div class="mb-3">
<div class="mb-1">
<label for="create_apiKey_validity" class="form-label" translate="Portal.CreateApiKey.Form.Validity"></label>
<select id="create_apiKey_validity" class="form-select" formControlName="validity">
<option value="1" [translate]="'Portal.CreateApiKey.Form.Validity1'"></option>
Expand All @@ -85,6 +85,10 @@
<option value="365" [translate]="'Portal.CreateApiKey.Form.Validity365'"></option>
</select>
</div>
<span
class="small text-secondary"
[translate]="'Portal.CreateApiKey.Form.ValidUntil'"
[translateParams]="{ date: validUntil | translateDate: 'longDate' }"></span>
</div>

<hr />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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] }),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -106,4 +114,8 @@ export class CreateApiKeyComponent {
});
}
}

private calculateValidUntil(): void {
this.validUntil = new Date(Date.now() + this.form.controls.validity.value * 86400000);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,13 +212,20 @@
<tp-action-button
icon="bar-chart-line"
type="outline-secondary"
[attr.aria-label]="'Portal.ViewOrganization.ApiKeys.ViewCharts' | translate"
[ngbTooltip]="'Portal.ViewOrganization.ApiKeys.ViewCharts' | translate"
[mode]="'IconOnly'"
(buttonClick)="displayApiKeyUsage = apiKey.id" />
</td>
@if (writeAllowed) {
<td class="align-middle" style="width: 1px">
<td class="align-middle text-nowrap" style="width: 1px">
<tp-action-button
[icon]="'clock-history'"
[type]="'outline-secondary'"
[mode]="'IconOnly'"
[ngbTooltip]="'Portal.ViewOrganization.ApiKeys.Extend.Tooltip' | translate"
(click)="showExtendApiKeyOffcanvas(apiKey)" />
<tp-action-button
class="ms-2"
[tpE2E]="['view-organization-page-delete-api-key-button', apiKey.id]"
[icon]="'trash'"
[type]="'outline-danger'"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ActivatedRoute, NavigationStart, Router, RouterLink } from '@angular/router';
import { mergeMap, Observable, of, Subject, switchMap, takeUntil, tap, zip } from 'rxjs';

import { NotificationService } from '../../../core/services/notification.service';
Expand Down Expand Up @@ -46,6 +46,10 @@ import { getImages } from '../../../api/fn/images/get-images';
import { GetImagesEndpointResponse } from '../../../api/models/get-images-endpoint-response';
import { FileSizePipe } from '../../pipes/file-size.pipe';
import { DeleteOffcanvasComponent } from '../../components/delete-offcanvas/delete-offcanvas.component';
import { NgbOffcanvas, NgbOffcanvasRef, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { ApiKeyExtendComponent } from '../../components/api-key-extend/api-key-extend.component';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs/operators';

@Component({
templateUrl: './view-organization.component.html',
Expand Down Expand Up @@ -75,7 +79,8 @@ import { DeleteOffcanvasComponent } from '../../components/delete-offcanvas/dele
E2eDirective,
ImageManagerComponent,
FileSizePipe,
DeleteOffcanvasComponent
DeleteOffcanvasComponent,
NgbTooltip
]
})
export class ViewOrganizationComponent implements OnInit, OnDestroy {
Expand Down Expand Up @@ -138,6 +143,8 @@ export class ViewOrganizationComponent implements OnInit, OnDestroy {
}
];

private extendApiKeyOffcanvas?: NgbOffcanvasRef;

private readonly destroyed$ = new Subject<void>();

constructor(
Expand All @@ -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
Expand Down Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IResult> 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<SetApiKeyExpiryDateRequest>
{
public static readonly Validator Instance = new();

private Validator()
{
RuleFor(x => x.Validity)
.GreaterThanOrEqualTo(1)
.LessThanOrEqualTo(365);
}
}
}
Loading
Loading