Skip to content
This repository has been archived by the owner on Jan 24, 2023. It is now read-only.

Commit

Permalink
Add UI for Stratos API Keys (#4523)
Browse files Browse the repository at this point in the history
* Add backend support for API keys

* Add last_used field to API keys

* Add base api keys page

* Add basic api key entity framework (untested)

* Add a basic api keys list (untested, need to wire in properties/columns + actions)

* Fix entity type related issues

* Add basic way to create api key

* Wire in delete to list

* Improve 'no api keys' ux

* Final tidy up

* Other fixes

* Fix unit tests

* Add 'Last Used' column to API keys list

* Don't flash up 'no entries' when we haven't loaded api keys yet

* Fix last used sorting
- takes into account timezone
- use cacheing to cater for often called sort

* Fix unit test after changes in master

* Fix after moment change

* Remove now unrequired sorting of api key last used date via moment

Co-authored-by: Ivan Kapelyukhin <ikapelyukhin@suse.com>
  • Loading branch information
richard-cox and ikapelyukhin committed Sep 2, 2020
1 parent f6f2d9f commit b560761
Show file tree
Hide file tree
Showing 26 changed files with 791 additions and 58 deletions.
1 change: 1 addition & 0 deletions src/frontend/packages/core/src/app.routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const appRoutes: Routes = [
},
{ path: 'about', loadChildren: () => import('./features/about/about.module').then(m => m.AboutModule) },
{ path: 'user-profile', loadChildren: () => import('./features/user-profile/user-profile.module').then(m => m.UserProfileModule) },
{ path: 'api-keys', loadChildren: () => import('./features/api-keys/api-keys.module').then(m => m.ApiKeysModule) },
{ path: 'events', loadChildren: () => import('./features/event-page/event-page.module').then(m => m.EventPageModule) },
{
path: 'errors/:endpointId',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<div class="key-dialog__loading-wrapper">
<mat-progress-bar class="key-dialog__loading"
[color]="(hasErrored$ | async) && !(isBusy$ | async) ? 'warn' : 'primary'"
[mode]="(isBusy$ | async) ? 'indeterminate' : 'solid'">
</mat-progress-bar>
</div>
<div class="key-dialog">
<div class="key-dialog__title">
<h2 mat-dialog-title>
Create an API Key
</h2>
</div>
<div>
</div>
<div [formGroup]="formGroup">
<mat-form-field>
<input matInput placeholder="Description" formControlName="comment" required>
</mat-form-field>
</div>

<app-dialog-error message="{{hasErrored$ | async}}" [show]="(hasErrored$ | async) && !(isBusy$ | async)">
</app-dialog-error>
<mat-dialog-actions class="key-dialog__actions">
<button [mat-dialog-close]="true" mat-button color="warn" [disabled]="(isBusy$ | async)">Cancel</button>
<button (click)="submit()" [disabled]="(isBusy$ | async) || !formGroup.valid" mat-button
color="primary">Create</button>
</mat-dialog-actions>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.key-dialog {
&__loading {
left: 0;
position: absolute;
right: 0;
top: 0;
&-wrapper {
position: relative;
margin: 0 -24px;
transform: translateY(-24px);
}
}

&__title {
display: flex;
h2 {
flex: 1;
}
}

&__actions {
justify-content: flex-end;
}

mat-form-field {
width: 100%;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialogRef } from '@angular/material/dialog';

import { BaseTestModules } from '../../../../test-framework/core-test.helper';
import { AddApiKeyDialogComponent } from './add-api-key-dialog.component';

describe('AddApiKeyDialogComponent', () => {
let component: AddApiKeyDialogComponent;
let fixture: ComponentFixture<AddApiKeyDialogComponent>;

const mockDialogRef = {
close: () => { }
};

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
...BaseTestModules,
],
declarations: [AddApiKeyDialogComponent],
providers: [
{
provide: MatDialogRef,
useValue: mockDialogRef
}
]
})
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(AddApiKeyDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Component, OnDestroy } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
import { BehaviorSubject, Subscription } from 'rxjs';
import { filter, first, map, pairwise, tap } from 'rxjs/operators';

import { ApiKey } from '../../../../../store/src/apiKey.types';
import { entityCatalog } from '../../../../../store/src/entity-catalog/entity-catalog';
import { RequestInfoState } from '../../../../../store/src/reducers/api-request-reducer/types';
import { stratosEntityCatalog } from '../../../../../store/src/stratos-entity-catalog';
import { NormalizedResponse } from '../../../../../store/src/types/api.types';
import { safeUnsubscribe } from '../../../core/utils.service';

@Component({
selector: 'app-add-api-key-dialog',
templateUrl: './add-api-key-dialog.component.html',
styleUrls: ['./add-api-key-dialog.component.scss']
})
export class AddApiKeyDialogComponent implements OnDestroy {

private hasErrored = new BehaviorSubject(null);
public hasErrored$ = this.hasErrored.asObservable();
private isBusy = new BehaviorSubject(false)
public isBusy$ = this.isBusy.asObservable();

private sub: Subscription;

public formGroup: FormGroup;

constructor(
private fb: FormBuilder,
public dialogRef: MatDialogRef<ApiKey>,
) {
this.formGroup = this.fb.group({
comment: ['', Validators.required],
});
}

ngOnDestroy(): void {
safeUnsubscribe(this.sub);
}

submit() {
this.sub = stratosEntityCatalog.apiKey.api.create<RequestInfoState>(this.formGroup.controls.comment.value).pipe(
tap(() => {
this.isBusy.next(true);
this.hasErrored.next(null);
}),
pairwise(),
filter(([oldR, newR]) => oldR.creating && !newR.creating),
map(([, newR]) => newR),
tap(state => {
if (state.error) {
this.hasErrored.next(`Failed to create key: ${state.message}`);
this.isBusy.next(false);
} else {
const response: NormalizedResponse<ApiKey> = state.response;
const entityKey = entityCatalog.getEntityKey(stratosEntityCatalog.apiKey.actions.create(''));
this.dialogRef.close(response.entities[entityKey][response.result[0]])
}
}),
first()
).subscribe()
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<app-page-header>
<h1>API Keys</h1>
<div class="page-header-right">
<button id="stratos-api-key" mat-icon-button (click)="addApiKey()" matTooltip="Create API Key">
<mat-icon>add</mat-icon>
</button>
</div>
</app-page-header>

<div class="keys-page__new" *ngIf="keyDetails$ | async as keyDetails">

<mat-card class="autoscaler-credential__card">
<mat-card-header>
<mat-icon>vpn_key</mat-icon>New API Key
</mat-card-header>
<mat-card-content>
<p>Your API Key has been successfully created. Use the following information to connect to Stratos.</p>
<p><i>Please safely record these details, there is no later way to view them</i></p>
<ul>
<li>Secret: {{keyDetails.secret}}</li>
</ul>
<button (click)="clearKeyDetails()" mat-button color="warn">Close</button>
</mat-card-content>
</mat-card>
</div>

<app-list *ngIf="(hasKeys$ | async) === true"></app-list>
<app-no-content-message *ngIf="(hasKeys$ | async) === false" icon="vpn_key" [firstLine]="'You have no API keys'"
[secondLine]="{
text: ''
}" [toolbarLink]="{
text: 'Create an API key'
}" toolbarAlign="stratos-api-key"></app-no-content-message>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.keys-page {
&__new {
mat-card {
margin-bottom: 24px;
mat-card-header {
align-items: center;
display: flex;
margin-bottom: 15px;
mat-icon {
margin-right: 5px;
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialogRef } from '@angular/material/dialog';

import { BaseTestModules } from '../../../../test-framework/core-test.helper';
import { TabNavService } from '../../../tab-nav.service';
import { ApiKeysPageComponent } from './api-keys-page.component';

describe('ApiKeysPageComponent', () => {
let component: ApiKeysPageComponent;
let fixture: ComponentFixture<ApiKeysPageComponent>;

const mockDialogRef = {
close: () => { }
};

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
...BaseTestModules,
],
declarations: [ApiKeysPageComponent],
providers: [
{
provide: MatDialogRef,
useValue: mockDialogRef
},
TabNavService
]
})
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(ApiKeysPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Component } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Observable, Subject } from 'rxjs';
import { first, map, startWith } from 'rxjs/operators';

import { stratosEntityCatalog } from '../../../../../store/src/stratos-entity-catalog';
import { ApiKeyListConfigService } from '../../../shared/components/list/list-types/apiKeys/apiKey-list-config.service';
import { ListConfig } from '../../../shared/components/list/list.component.types';
import { AddApiKeyDialogComponent } from '../add-api-key-dialog/add-api-key-dialog.component';

@Component({
selector: 'app-api-keys-page',
templateUrl: './api-keys-page.component.html',
styleUrls: ['./api-keys-page.component.scss'],
providers: [{
provide: ListConfig,
useClass: ApiKeyListConfigService,
}]
})
export class ApiKeysPageComponent {

public keyDetails = new Subject<string>();
public keyDetails$ = this.keyDetails.asObservable();
public hasKeys$: Observable<Boolean>;

constructor(
private dialog: MatDialog,
) {
this.hasKeys$ = stratosEntityCatalog.apiKey.store.getPaginationService().entities$.pipe(
map(entities => entities && !!entities.length),
startWith(null),
)
}

addApiKey() {
this.showDialog().pipe(first()).subscribe(key => {
this.keyDetails.next(key);
})
}

clearKeyDetails() {
this.keyDetails.next();
}

private showDialog(): Observable<string> {
return this.dialog.open(AddApiKeyDialogComponent, {
disableClose: true,
}).afterClosed().pipe(
map(newApiKey => {
if (newApiKey && newApiKey.guid) {
stratosEntityCatalog.apiKey.api.getMultiple();
return newApiKey;
}
return null;
})
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';

import { CoreModule } from '../../core/core.module';
import { SharedModule } from '../../shared/shared.module';
import { ApiKeysPageComponent } from './api-keys-page/api-keys-page.component';
import { ApiKeysRoutingModule } from './api-keys.routing';
import { AddApiKeyDialogComponent } from './add-api-key-dialog/add-api-key-dialog.component';


@NgModule({
imports: [
CoreModule,
SharedModule,
ApiKeysRoutingModule,
],
declarations: [
ApiKeysPageComponent,
AddApiKeyDialogComponent
]
})
export class ApiKeysModule { }

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

import { ApiKeysPageComponent } from './api-keys-page/api-keys-page.component';

const apiKeys: Routes = [
{
path: '',
component: ApiKeysPageComponent
},
];

@NgModule({
imports: [
RouterModule.forChild(apiKeys),
]
})
export class ApiKeysRoutingModule { }
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ <h1>Endpoints</h1>
[routerLink]="'/endpoints/new/'" matTooltip="Register Endpoint">
<mat-icon>add</mat-icon>
</button>
<button id="stratos-add-endpoint" *appUserPermission="canRegisterEndpoint" mat-icon-button
<button id="stratos-restore-backup" *appUserPermission="canRegisterEndpoint" mat-icon-button
[routerLink]="'/endpoints/backup-restore'" matTooltip="Backup/Restore Endpoints">
<mat-icon>settings_backup_restore</mat-icon>
</button>
Expand Down
Loading

0 comments on commit b560761

Please sign in to comment.