Skip to content
Open
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
10 changes: 9 additions & 1 deletion frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import {
MatLegacyTooltipDefaultOptions as MatTooltipDefaultOptions,
MatLegacyTooltipModule as MatTooltipModule
} from "@angular/material/legacy-tooltip";
import {ClipboardModule} from "@angular/cdk/clipboard";
import {MatDatepickerModule} from "@angular/material/datepicker";
import {MatNativeDateModule} from "@angular/material/core";
import {BrowserModule, DomSanitizer} from '@angular/platform-browser';
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";

Expand Down Expand Up @@ -77,6 +80,7 @@ import {AuthGuardService} from "./session/auth-guard.service";
import {AuthInterceptor} from "./session/auth.interceptor";
import {AuthService} from "./session/auth.service";
import {IsAdminOrDoerGuardService} from "./session/is-admin-or-doer-guard.service";
import {CreateApiTokenDialogComponent} from "./user/api-token-dialog/create-api-token-dialog.component";
import {UserEditDialog} from "./user/edit-dialog/user-edit-dialog.component";
import {UserListComponent} from "./user/list/user-list.component";
import {MeComponent} from "./user/me/me.component";
Expand Down Expand Up @@ -196,7 +200,8 @@ export function HttpLoaderFactory(http: HttpClient) {
SearchPageComponent,
DeploymentUrlsComponent,
ContainerLogsComponent,
SafeHtmlPipe
SafeHtmlPipe,
CreateApiTokenDialogComponent
],
imports: [
BrowserModule,
Expand Down Expand Up @@ -235,6 +240,9 @@ export function HttpLoaderFactory(http: HttpClient) {
MatStepperModule,
MatCheckboxModule,
MatRadioModule,
MatDatepickerModule,
MatNativeDateModule,
ClipboardModule,
NgxMatSelectSearchModule,
NgxsModule.forRoot(appStates, {developmentMode: !environment.production}),
NgxsStoragePluginModule.forRoot({key: [ThemingState, I18nState]}),
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/app/rest/rest.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {Injectable} from "@angular/core";
import {Observable} from "rxjs";
import {map, tap} from "rxjs/operators";
import {AuthService} from "../session/auth.service";
import {ApiToken, ApiTokenDTO, CreateApiTokenRequest, CreateApiTokenResponse} from "../user/api-token";
import {ChangePasswordDTO} from "../user/change-password-dto";
import {User, UserDTO} from "../user/user";
import {ActivityRestService} from "./activity-rest.service";
Expand Down Expand Up @@ -101,6 +102,22 @@ export class RestService {
.pipe(map(dto => dto.available));
}

/*------------------------------------------------------------
* API Tokens
------------------------------------------------------------*/

public getApiTokens(username: string): Observable<Array<ApiToken>> {
return this.http.get<Array<ApiTokenDTO>>(`/api/user/${username}/tokens`)
.pipe(map(tokens => tokens.map(ApiToken.from)));
}

public createApiToken(username: string, request: CreateApiTokenRequest): Observable<CreateApiTokenResponse> {
return this.http.post<CreateApiTokenResponse>(`/api/user/${username}/tokens`, request);
}

public deleteApiToken(username: string, tokenId: string): Observable<any> {
return this.http.delete(`/api/user/${username}/tokens/${tokenId}`);
}

/*------------------------------------------------------------
* delegates
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<h2 mat-dialog-title>{{ 'components.user.me.apiTokens.createTitle' | translate }}</h2>
<mat-dialog-content>
<!-- Step 1: Create -->
<div *ngIf="!createdToken" fxLayout="column" fxLayoutGap="1em">
<mat-form-field>
<mat-label>{{ 'components.user.me.apiTokens.tokenName' | translate }}</mat-label>
<input matInput [(ngModel)]="tokenName" required placeholder="e.g. CI Pipeline">
</mat-form-field>

<mat-form-field>
<mat-label>{{ 'components.user.me.apiTokens.expiresAt' | translate }}</mat-label>
<input matInput [matDatepicker]="picker" [(ngModel)]="expiresAt" [min]="minDate" [max]="maxDate">
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
<mat-hint>{{ 'components.user.me.apiTokens.expiresAtHint' | translate }}</mat-hint>
</mat-form-field>
</div>

<!-- Step 2: Show token -->
<div *ngIf="createdToken" fxLayout="column" fxLayoutGap="1em">
<div class="token-warning">
<mat-icon svgIcon="mdi:alert"></mat-icon>
<span>{{ 'components.user.me.apiTokens.tokenWarning' | translate }}</span>
</div>

<mat-form-field>
<mat-label>{{ 'components.user.me.apiTokens.yourToken' | translate }}</mat-label>
<input matInput [value]="createdToken.rawToken" readonly>
<button matSuffix mat-icon-button (click)="copyToken()" matTooltip="{{ 'components.user.me.apiTokens.copy' | translate }}">
<mat-icon svgIcon="mdi:content-copy"></mat-icon>
</button>
</mat-form-field>
</div>
</mat-dialog-content>

<mat-dialog-actions align="end">
<button mat-button *ngIf="!createdToken" (click)="close()">{{ 'general.cancel' | translate }}</button>
<button mat-raised-button color="primary" *ngIf="!createdToken" (click)="createToken()" [disabled]="!tokenName || isCreating">
{{ 'components.user.me.apiTokens.create' | translate }}
</button>
<button mat-raised-button color="primary" *ngIf="createdToken" (click)="close()">
{{ 'general.ok' | translate }}
</button>
</mat-dialog-actions>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.token-warning {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: 4px;
background-color: rgba(255, 152, 0, 0.1);
color: #ff9800;

mat-icon {
flex-shrink: 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {Component} from "@angular/core";
import {MatLegacyDialogRef as MatDialogRef} from "@angular/material/legacy-dialog";
import {MatLegacySnackBar as MatSnackBar} from "@angular/material/legacy-snack-bar";
import {Clipboard} from "@angular/cdk/clipboard";
import {TranslateService} from "@ngx-translate/core";
import {RestService} from "../../rest/rest.service";
import {CreateApiTokenResponse} from "../api-token";

@Component({
selector: 'create-api-token-dialog',
templateUrl: './create-api-token-dialog.component.html',
styleUrls: ['./create-api-token-dialog.component.scss']
})
export class CreateApiTokenDialogComponent {

tokenName: string = '';
expiresAt: Date | null = null;
username: string;
minDate: Date = new Date();
maxDate: Date = new Date(new Date().setFullYear(new Date().getFullYear() + 1));

createdToken: CreateApiTokenResponse | null = null;
isCreating: boolean = false;

constructor(public dialogRef: MatDialogRef<CreateApiTokenDialogComponent>,
private rest: RestService,
private snackBar: MatSnackBar,
private clipboard: Clipboard,
private readonly translate: TranslateService) {
}

createToken(): void {
if (!this.tokenName || this.isCreating) {
return;
}

this.isCreating = true;
const request = {
name: this.tokenName,
expiresAt: this.expiresAt ? this.expiresAt.toISOString() : null
};

this.rest.createApiToken(this.username, request).subscribe({
next: (response) => {
this.createdToken = response;
this.isCreating = false;
},
error: () => {
this.isCreating = false;
this.snackBar.open(this.translate.instant('components.user.me.apiTokens.createError'), null, {duration: 3000});
}
});
}

copyToken(): void {
if (this.createdToken) {
this.clipboard.copy(this.createdToken.rawToken);
this.snackBar.open(this.translate.instant('components.user.me.apiTokens.copied'), null, {duration: 2000});
}
}

close(): void {
this.dialogRef.close(this.createdToken != null);
}
}
42 changes: 42 additions & 0 deletions frontend/src/app/user/api-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export interface ApiTokenDTO {
id: string;
name: string;
createdAt: string;
expiresAt: string | null;
lastUsedAt: string | null;
}

export interface CreateApiTokenRequest {
name: string;
expiresAt: string | null;
}

export interface CreateApiTokenResponse {
token: ApiTokenDTO;
rawToken: string;
}

export class ApiToken implements ApiTokenDTO {
id: string;
name: string;
createdAt: string;
expiresAt: string | null;
lastUsedAt: string | null;

static from(dto: ApiTokenDTO): ApiToken {
const token = new ApiToken();
token.id = dto.id;
token.name = dto.name;
token.createdAt = dto.createdAt;
token.expiresAt = dto.expiresAt;
token.lastUsedAt = dto.lastUsedAt;
return token;
}

isExpired(): boolean {
if (!this.expiresAt) {
return false;
}
return new Date(this.expiresAt) < new Date();
}
}
48 changes: 48 additions & 0 deletions frontend/src/app/user/me/me.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,54 @@
<span>{{me.role}}</span>
</div>
</div>

<!-- API Tokens Section -->
<div class="api-tokens-section" fxLayout="column" fxLayoutGap="1em" style="width: 100%; max-width: 700px;">
<div fxLayout="row" fxLayoutAlign="space-between center">
<h3>{{ 'components.user.me.apiTokens.title' | translate }}</h3>
<button mat-raised-button color="primary" (click)="createApiToken()">
<mat-icon svgIcon="mdi:plus"></mat-icon>
{{ 'components.user.me.apiTokens.create' | translate }}
</button>
</div>

<div *ngIf="apiTokens.length === 0" class="no-tokens">
{{ 'components.user.me.apiTokens.noTokens' | translate }}
</div>

<table *ngIf="apiTokens.length > 0" style="width: 100%;">
<thead>
<tr>
<th>{{ 'components.user.me.apiTokens.columnName' | translate }}</th>
<th>{{ 'components.user.me.apiTokens.columnCreated' | translate }}</th>
<th>{{ 'components.user.me.apiTokens.columnExpires' | translate }}</th>
<th>{{ 'components.user.me.apiTokens.columnLastUsed' | translate }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let token of apiTokens">
<td>{{token.name}}</td>
<td>{{token.createdAt | date:'medium'}}</td>
<td>
<span *ngIf="token.expiresAt">{{token.expiresAt | date:'medium'}}</span>
<span *ngIf="!token.expiresAt">{{ 'components.user.me.apiTokens.never' | translate }}</span>
</td>
<td>
<span *ngIf="token.lastUsedAt">{{token.lastUsedAt | date:'medium'}}</span>
<span *ngIf="!token.lastUsedAt">{{ 'components.user.me.apiTokens.neverUsed' | translate }}</span>
</td>
<td>
<button mat-icon-button color="warn" (click)="deleteApiToken(token)"
matTooltip="{{ 'components.user.me.apiTokens.delete' | translate }}">
<mat-icon svgIcon="mdi:delete"></mat-icon>
</button>
</td>
</tr>
</tbody>
</table>
</div>

<div>
<button mat-button color="warn" (click)="deleteAccount()">{{ 'components.user.me.deleteAccount' | translate }}</button>
</div>
Expand Down
47 changes: 46 additions & 1 deletion frontend/src/app/user/me/me.component.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {Component} from "@angular/core";
import {MatLegacyDialog as MatDialog} from "@angular/material/legacy-dialog";
import {MatLegacySnackBar as MatSnackBar} from "@angular/material/legacy-snack-bar";
import {Router} from "@angular/router";

import {RestService} from "../../rest/rest.service";
import {AuthService} from "../../session/auth.service";
import {ConfirmDialog, ConfirmDialogData} from "../../util/confirm-dialog/confirm-dialog.component";
import {CreateApiTokenDialogComponent} from "../api-token-dialog/create-api-token-dialog.component";
import {ApiToken} from "../api-token";
import {UserEditDialog} from "../edit-dialog/user-edit-dialog.component";
import {User} from "../user";
import {zip} from "rxjs";
Expand All @@ -18,13 +21,18 @@ import {TranslateService} from "@ngx-translate/core";
export class MeComponent {

public me: User;
public apiTokens: ApiToken[] = [];

constructor(private rest: RestService,
private dialog: MatDialog,
private snackBar: MatSnackBar,
private auth: AuthService,
private router: Router,
private readonly translate: TranslateService) {
this.rest.currentUser().subscribe(user => this.me = user);
this.rest.currentUser().subscribe(user => {
this.me = user;
this.loadTokens();
});
}

public editAccount() {
Expand Down Expand Up @@ -57,4 +65,41 @@ export class MeComponent {
});
}

public createApiToken() {
const dialogRef = this.dialog.open(CreateApiTokenDialogComponent, {
width: "500px"
});
dialogRef.componentInstance.username = this.me.username;
dialogRef.afterClosed().subscribe(created => {
if (created) {
this.loadTokens();
}
});
}

public deleteApiToken(token: ApiToken) {
this.dialog.open(ConfirmDialog, {
data: <ConfirmDialogData>{
title: this.translate.instant('components.user.me.apiTokens.deleteTitle'),
message: this.translate.instant('components.user.me.apiTokens.deleteMessage', {name: token.name}),
okButtonText: this.translate.instant('components.user.me.apiTokens.deleteConfirm')
},
width: "50%"
}).afterClosed().subscribe(result => {
if (result === true) {
this.rest.deleteApiToken(this.me.username, token.id).subscribe(() => {
this.loadTokens();
this.snackBar.open(this.translate.instant('components.user.me.apiTokens.deleted'), null, {duration: 2000});
});
}
});
}

private loadTokens() {
if (this.me) {
this.rest.getApiTokens(this.me.username).subscribe(tokens => {
this.apiTokens = tokens;
});
}
}
}
25 changes: 25 additions & 0 deletions frontend/src/assets/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,31 @@
"title": "Soll Ihr Account gelöscht werden?",
"message": "Bitte bestätigen Sie das Löschen Ihres Accounts. Diese Aktion kann nicht rückgängig gemacht werden.",
"okButtonText": "Löschen"
},
"apiTokens": {
"title": "API-Tokens",
"create": "Token erstellen",
"createTitle": "API-Token erstellen",
"tokenName": "Token-Name",
"expiresAt": "Gültig bis",
"expiresAtHint": "Leer lassen für unbegrenzte Gültigkeit",
"noTokens": "Noch keine API-Tokens erstellt.",
"columnName": "Name",
"columnCreated": "Erstellt",
"columnExpires": "Gültig bis",
"columnLastUsed": "Zuletzt verwendet",
"never": "Nie",
"neverUsed": "Nie",
"delete": "Löschen",
"deleteTitle": "API-Token löschen?",
"deleteMessage": "Sind Sie sicher, dass Sie den Token \"{{name}}\" löschen möchten? Alle Anwendungen, die diesen Token verwenden, verlieren den Zugriff.",
"deleteConfirm": "Löschen",
"deleted": "Token gelöscht",
"yourToken": "Ihr API-Token",
"tokenWarning": "Kopieren Sie den Token jetzt. Er wird nicht erneut angezeigt!",
"copy": "In Zwischenablage kopieren",
"copied": "Token in Zwischenablage kopiert",
"createError": "Token konnte nicht erstellt werden"
}
}
}
Expand Down
Loading