OPS-280: New API Keys Module (#122)

This commit is contained in:
Rinat Arsaev 2023-05-03 16:52:15 +04:00 committed by GitHub
parent 57db0bbe3d
commit 8ee6946797
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 750 additions and 53 deletions

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="PrettierConfiguration"> <component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" /> <option name="myRunOnSave" value="true" />
<option name="myFilesPattern" value="{**/*,*}.{html,js,ts,css,scss,md,json,prettierrc,svg,yaml,yml}" /> <option name="myFilesPattern" value="{**/*,*}.{html,js,ts,css,scss,md,json,prettierrc,svg,yaml,yml}" />
</component> </component>

View File

@ -1,10 +1,4 @@
{ {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.tslint": true
},
"javascript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"cSpell.words": [ "cSpell.words": [
"CAPI", "CAPI",
@ -46,6 +40,5 @@
"инвойса" "инвойса"
], ],
"cSpell.language": "en,ru", "cSpell.language": "en,ru",
"prettier.prettierPath": "node_modules/prettier", "prettier.prettierPath": "node_modules/prettier"
"tasksStatusbar.taskLabelFilter": "Start",
} }

17
.vscode/tasks.json vendored
View File

@ -1,17 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"problemMatcher": [],
"label": "Start dev"
},
{
"type": "npm",
"script": "stage",
"problemMatcher": [],
"label": "Start stage"
}
]
}

47
package-lock.json generated
View File

@ -31,7 +31,9 @@
"@sentry/angular": "7.7.0", "@sentry/angular": "7.7.0",
"@sentry/integrations": "7.7.0", "@sentry/integrations": "7.7.0",
"@sentry/tracing": "7.7.0", "@sentry/tracing": "7.7.0",
"@vality/ng-core": "^0.3.0",
"@vality/swag-anapi-v2": "2.0.1-38f360b.0", "@vality/swag-anapi-v2": "2.0.1-38f360b.0",
"@vality/swag-api-keys": "^1.0.0",
"@vality/swag-claim-management": "0.1.1-bfc2e6c.0", "@vality/swag-claim-management": "0.1.1-bfc2e6c.0",
"@vality/swag-organizations": "1.0.1-cd6cc10.0", "@vality/swag-organizations": "1.0.1-cd6cc10.0",
"@vality/swag-payments": "0.1.1-01da4bb.0", "@vality/swag-payments": "0.1.1-01da4bb.0",
@ -6048,6 +6050,23 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@vality/ng-core": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@vality/ng-core/-/ng-core-0.3.0.tgz",
"integrity": "sha512-uezgCzRGWTfe6PDnAqbFQ0nqCv+8auIGTd1tej8hV+h0fTOahAJybTrpYpzvegYdVJ1IwVAUqMAiLKo5RCtKhQ==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/cdk": ">=15.0.0",
"@angular/common": ">=15.0.0",
"@angular/core": ">=15.0.0",
"@angular/material": ">=15.0.0",
"coerce-property": ">=0.3.2",
"lodash-es": "^4.0.0",
"utility-types": ">=3.0.0"
}
},
"node_modules/@vality/swag-anapi-v2": { "node_modules/@vality/swag-anapi-v2": {
"version": "2.0.1-38f360b.0", "version": "2.0.1-38f360b.0",
"resolved": "https://registry.npmjs.org/@vality/swag-anapi-v2/-/swag-anapi-v2-2.0.1-38f360b.0.tgz", "resolved": "https://registry.npmjs.org/@vality/swag-anapi-v2/-/swag-anapi-v2-2.0.1-38f360b.0.tgz",
@ -6060,6 +6079,18 @@
"@angular/core": ">=14.0.0" "@angular/core": ">=14.0.0"
} }
}, },
"node_modules/@vality/swag-api-keys": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@vality/swag-api-keys/-/swag-api-keys-1.0.0.tgz",
"integrity": "sha512-UjPzFVLy3dccgW14MnNlVUfRDIIiZGlYhtgCxBOFoPIyf954BsVeUOm/f4gpZXwzDhU6ge60HAUzTCxlu5/Sew==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=14.0.0",
"@angular/core": ">=14.0.0"
}
},
"node_modules/@vality/swag-claim-management": { "node_modules/@vality/swag-claim-management": {
"version": "0.1.1-bfc2e6c.0", "version": "0.1.1-bfc2e6c.0",
"license": "Apache-2.0", "license": "Apache-2.0",
@ -22478,6 +22509,14 @@
} }
} }
}, },
"@vality/ng-core": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@vality/ng-core/-/ng-core-0.3.0.tgz",
"integrity": "sha512-uezgCzRGWTfe6PDnAqbFQ0nqCv+8auIGTd1tej8hV+h0fTOahAJybTrpYpzvegYdVJ1IwVAUqMAiLKo5RCtKhQ==",
"requires": {
"tslib": "^2.3.0"
}
},
"@vality/swag-anapi-v2": { "@vality/swag-anapi-v2": {
"version": "2.0.1-38f360b.0", "version": "2.0.1-38f360b.0",
"resolved": "https://registry.npmjs.org/@vality/swag-anapi-v2/-/swag-anapi-v2-2.0.1-38f360b.0.tgz", "resolved": "https://registry.npmjs.org/@vality/swag-anapi-v2/-/swag-anapi-v2-2.0.1-38f360b.0.tgz",
@ -22486,6 +22525,14 @@
"tslib": "^2.3.0" "tslib": "^2.3.0"
} }
}, },
"@vality/swag-api-keys": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@vality/swag-api-keys/-/swag-api-keys-1.0.0.tgz",
"integrity": "sha512-UjPzFVLy3dccgW14MnNlVUfRDIIiZGlYhtgCxBOFoPIyf954BsVeUOm/f4gpZXwzDhU6ge60HAUzTCxlu5/Sew==",
"requires": {
"tslib": "^2.3.0"
}
},
"@vality/swag-claim-management": { "@vality/swag-claim-management": {
"version": "0.1.1-bfc2e6c.0", "version": "0.1.1-bfc2e6c.0",
"requires": { "requires": {

View File

@ -49,7 +49,9 @@
"@sentry/angular": "7.7.0", "@sentry/angular": "7.7.0",
"@sentry/integrations": "7.7.0", "@sentry/integrations": "7.7.0",
"@sentry/tracing": "7.7.0", "@sentry/tracing": "7.7.0",
"@vality/ng-core": "^0.3.0",
"@vality/swag-anapi-v2": "2.0.1-38f360b.0", "@vality/swag-anapi-v2": "2.0.1-38f360b.0",
"@vality/swag-api-keys": "^1.0.0",
"@vality/swag-claim-management": "0.1.1-bfc2e6c.0", "@vality/swag-claim-management": "0.1.1-bfc2e6c.0",
"@vality/swag-organizations": "1.0.1-cd6cc10.0", "@vality/swag-organizations": "1.0.1-cd6cc10.0",
"@vality/swag-payments": "0.1.1-01da4bb.0", "@vality/swag-payments": "0.1.1-01da4bb.0",

View File

@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { Configuration } from '@vality/swag-api-keys';
import { ConfigService } from '../../config';
@NgModule({
providers: [
{
provide: Configuration,
deps: [ConfigService],
useFactory: (configService: ConfigService) =>
new Configuration({ basePath: `${configService.apiEndpoint}/apikeys/v1` }),
},
],
})
export class ApiKeysModule {}

View File

@ -0,0 +1,10 @@
import { Injectable } from '@angular/core';
import { ApiKeysService as ApiService } from '@vality/swag-api-keys';
import { createApi } from '../utils';
import { PartyIdExtension } from '../utils/extensions';
@Injectable({
providedIn: 'root',
})
export class ApiKeysService extends createApi(ApiService, [PartyIdExtension]) {}

View File

@ -0,0 +1,2 @@
export * from './api-keys.service';
export * from './api-keys.module';

View File

@ -14,7 +14,7 @@ export class PartyIdExtension implements ApiExtension {
selector() { selector() {
return this.contextOrganizationService.organization$.pipe( return this.contextOrganizationService.organization$.pipe(
first(), first(),
map(({ party }) => ({ partyID: party })) map(({ party }) => ({ partyID: party, partyId: party }))
); );
} }
} }

View File

@ -27,6 +27,7 @@ import { QUERY_PARAMS_SERIALIZERS } from '@dsh/app/shared/services/query-params/
import { createDateRangeWithPresetSerializer } from '@dsh/components/date-range-filter'; import { createDateRangeWithPresetSerializer } from '@dsh/components/date-range-filter';
import { SpinnerModule } from '@dsh/components/indicators'; import { SpinnerModule } from '@dsh/components/indicators';
import { ApiKeysModule } from './api/api-keys';
import { OrganizationsModule } from './api/organizations'; import { OrganizationsModule } from './api/organizations';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { AuthModule, KeycloakAngularModule, KeycloakService } from './auth'; import { AuthModule, KeycloakAngularModule, KeycloakService } from './auth';
@ -66,6 +67,7 @@ import { ENV, environment } from '../environments';
QuestionaryAggrProxyModule, QuestionaryAggrProxyModule,
WalletModule, WalletModule,
SpinnerModule, SpinnerModule,
ApiKeysModule,
], ],
providers: [ providers: [
LanguageService, LanguageService,

View File

@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ApiKey } from '@vality/swag-api-keys';
import { Observable } from 'rxjs';
import { ExpandedIdManager } from '@dsh/app/shared/services';
import { FetchApiKeysService } from './fetch-api-keys.service';
@Injectable()
export class ApiKeysExpandedIdManager extends ExpandedIdManager<ApiKey> {
constructor(
protected route: ActivatedRoute,
protected router: Router,
private fetchApiKeysService: FetchApiKeysService
) {
super(route, router);
}
protected get dataSet$(): Observable<ApiKey[]> {
return this.fetchApiKeysService.apiKeys$;
}
}

View File

@ -0,0 +1,63 @@
<div
fxLayout="column"
fxLayoutGap="16px"
*transloco="let t; scope: 'payment-section'; read: 'paymentSection.apiKeys.list'"
>
<dsh-last-updated [lastUpdated]="lastUpdated" (update)="refreshData.emit()"></dsh-last-updated>
<dsh-row fxLayout="row" fxLayoutAlign="space-between center" fxLayoutGap="24px">
<dsh-row-header-label fxFlex="50" fxFlex.lt-md="100">{{ t('name') | uppercase }}</dsh-row-header-label>
<dsh-row-header-label fxFlex="25" fxHide.lt-md>{{ t('createdAt') | uppercase }}</dsh-row-header-label>
<dsh-row-header-label fxFlex="25" fxHide.lt-md>{{ t('status') | uppercase }}</dsh-row-header-label>
</dsh-row>
<dsh-accordion
fxLayout="column"
fxLayoutGap="16px"
(expandedChange)="expandedIdChange.emit($event)"
[expanded]="expandedId"
>
<dsh-accordion-item *ngFor="let apiKey of apiKeys" #accordionItem>
<dsh-row fxLayout="row" fxLayoutAlign="space-between center" fxLayoutGap="24px">
<dsh-row-label class="wrap-url" fxFlex="50" fxFlex.lt-md="100">
{{ apiKey.name }}
</dsh-row-label>
<dsh-row-label fxFlex="25" fxHide.lt-md>{{
apiKey.createdAt | date: 'dd MMMM yyyy, HH:mm'
}}</dsh-row-label>
<dsh-row-label fxFlex="25" fxHide.lt-md>{{ apiKey.status }}</dsh-row-label>
</dsh-row>
<dsh-accordion-item-content>
<dsh-card fxLayout="column" fxLayoutGap="32px">
<dsh-accordion-item-content-header (collapse)="accordionItem.collapse($event)">
<div fxLayout="row" fxLayoutAlign="space-between center">
<div>{{ t('details') }} #{{ apiKey.id }}</div>
<div>{{ apiKey.createdAt | date: 'dd MMMM yyyy, HH:mm' }}</div>
</div>
</dsh-accordion-item-content-header>
<div fxLayout="column" fxLayoutGap="24px">
<div fxLayout="column" gdColumns="1fr 1fr 1fr" gdColumns.lt-md="1fr" gdGap="24px">
<dsh-details-item [title]="t('name')">
<div class="dsh-body-1">
{{ apiKey.name }}
</div>
</dsh-details-item>
<dsh-details-item [title]="t('status')">
<div class="dsh-body-1">{{ apiKey.status }}</div>
</dsh-details-item>
<dsh-details-item [title]="t('createdAt')">
<div class="dsh-body-1">{{ apiKey.createdAt | date: 'dd MMMM yyyy, HH:mm' }}</div>
</dsh-details-item>
</div>
<mat-divider></mat-divider>
<div fxLayout="column" fxLayoutGap="24px">
<div>
<button dsh-button color="warn" (click)="delete(apiKey)">
{{ t('delete') }}
</button>
</div>
</div>
</div>
</dsh-card>
</dsh-accordion-item-content>
</dsh-accordion-item>
</dsh-accordion>
</div>

View File

@ -0,0 +1,25 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { UntilDestroy } from '@ngneat/until-destroy';
import { DialogService } from '@vality/ng-core';
import { ApiKey } from '@vality/swag-api-keys';
import { ApiKeyDeleteDialogComponent } from './components/api-key-delete-dialog/api-key-delete-dialog.component';
@UntilDestroy()
@Component({
selector: 'dsh-api-keys-list',
templateUrl: 'api-keys-list.component.html',
})
export class ApiKeysListComponent {
@Input() apiKeys: ApiKey[];
@Input() expandedId: number;
@Input() lastUpdated: string;
@Output() expandedIdChange = new EventEmitter<number>();
@Output() refreshData = new EventEmitter<void>();
constructor(private dialogService: DialogService) {}
delete(apiKey: ApiKey) {
this.dialogService.open(ApiKeyDeleteDialogComponent, { apiKeyId: apiKey.id });
}
}

View File

@ -0,0 +1,32 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { MatDividerModule } from '@angular/material/divider';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { TranslocoModule } from '@ngneat/transloco';
import { DialogModule } from '@vality/ng-core';
import { ButtonModule } from '@dsh/components/buttons';
import { IndicatorsModule } from '@dsh/components/indicators';
import { LayoutModule } from '@dsh/components/layout';
import { ApiKeysListComponent } from './api-keys-list.component';
import { ApiKeyDeleteDialogComponent } from './components/api-key-delete-dialog/api-key-delete-dialog.component';
@NgModule({
imports: [
TranslocoModule,
MatSnackBarModule,
LayoutModule,
FlexLayoutModule,
CommonModule,
IndicatorsModule,
MatDividerModule,
ButtonModule,
ApiKeyDeleteDialogComponent,
DialogModule,
],
declarations: [ApiKeysListComponent],
exports: [ApiKeysListComponent],
})
export class ApiKeysListModule {}

View File

@ -0,0 +1,14 @@
<dsh-base-dialog
*transloco="let t; scope: 'payment-section'; read: 'paymentSection.apiKeys.deleteDialog'"
[title]="t('title')"
(cancel)="closeWithCancellation()"
>
<div class="dsh-body-1">
{{ t('desc') }}
</div>
<ng-container dshBaseDialogActions>
<button dsh-button (click)="confirm()" color="warn">
{{ t('confirm') }}
</button>
</ng-container>
</dsh-base-dialog>

View File

@ -0,0 +1,49 @@
import { Component, Injector } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { TranslocoModule } from '@ngneat/transloco';
import { untilDestroyed, UntilDestroy } from '@ngneat/until-destroy';
import { DialogSuperclass } from '@vality/ng-core';
import { RequestRevokeApiKeyRequestParams } from '@vality/swag-api-keys';
import { ApiKeysService } from '@dsh/api/api-keys';
import { BaseDialogModule } from '@dsh/app/shared/components/dialog/base-dialog';
import { ErrorService, NotificationService } from '@dsh/app/shared/services';
import { ButtonModule } from '@dsh/components/buttons';
import { SpinnerModule } from '@dsh/components/indicators';
@UntilDestroy()
@Component({
selector: 'dsh-api-key-delete-dialog',
standalone: true,
templateUrl: './api-key-delete-dialog.component.html',
styles: [],
imports: [BaseDialogModule, SpinnerModule, FlexModule, ButtonModule, TranslocoModule],
})
export class ApiKeyDeleteDialogComponent extends DialogSuperclass<
ApiKeyDeleteDialogComponent,
Pick<RequestRevokeApiKeyRequestParams, 'apiKeyId'>
> {
constructor(
injector: Injector,
private apiKeysService: ApiKeysService,
private errorService: ErrorService,
private notificationService: NotificationService
) {
super(injector);
}
confirm() {
this.apiKeysService
.requestRevokeApiKey(this.dialogData)
.pipe(untilDestroyed(this))
.subscribe({
next: () => {
this.notificationService.success();
this.closeWithSuccess();
},
error: (err) => {
this.errorService.error(err);
},
});
}
}

View File

@ -0,0 +1,2 @@
export * from './webhook-list.module';
export * from './webhook-list.component';

View File

@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ApiKeysComponent } from './api-keys.component';
import { ApiKeyRevokeComponent } from './components/api-key-revoke/api-key-revoke.component';
const ROUTES: Routes = [
{
path: '',
component: ApiKeysComponent,
},
{
path: ':apiKeyId/revoke/:apiKeyRevokeToken',
component: ApiKeyRevokeComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(ROUTES)],
})
export class ApiKeysRoutingModule {}

View File

@ -0,0 +1,36 @@
<div fxLayout="column" fxLayoutGap="32px" *transloco="let t; scope: 'payment-section'; read: 'paymentSection.apiKeys'">
<div
fxLayout.lt-md="column"
fxLayout="row"
fxLayoutAlign.lt-md="center stretch"
fxLayoutAlign="space-between"
fxLayoutGap="24px"
>
<div fxLayoutAlign=" center" fxLayoutGap="16px" (click)="toggle()" style="cursor: pointer">
<div class="dsh-subheading-1">
{{ t('showInactive') }}
</div>
<mat-slide-toggle [checked]="showInactive"></mat-slide-toggle>
</div>
<button dsh-button color="accent" (click)="create()">{{ t('create') }}</button>
</div>
<ng-container *ngIf="(isLoading$ | async) || !(apiKeys$ | async); else keysList">
<div fxLayout fxFlexAlign="center">
<dsh-spinner></dsh-spinner>
</div>
</ng-container>
<ng-template #keysList>
<dsh-api-keys-list
[apiKeys]="apiKeys$ | async"
[expandedId]="expandedId$ | async"
(expandedIdChange)="expandedIdChange($event)"
[lastUpdated]="lastUpdated$ | async"
(refreshData)="update()"
></dsh-api-keys-list>
<dsh-empty-search-result
*ngIf="(apiKeys$ | async)?.length === 0"
[text]="t('emptyResult')"
></dsh-empty-search-result>
</ng-template>
</div>

View File

@ -0,0 +1,3 @@
:host {
width: 100%;
}

View File

@ -0,0 +1,55 @@
import { Component } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { DialogService } from '@vality/ng-core';
import { ApiKeyStatus, ListApiKeysRequestParams } from '@vality/swag-api-keys';
import { ApiKeyCreateDialogComponent } from '@dsh/app/sections/payment-section/integrations/api-keys/components/api-key-create-dialog/api-key-create-dialog.component';
import { ApiKeysExpandedIdManager } from './api-keys-expanded-id-manager.service';
import { FetchApiKeysService } from './fetch-api-keys.service';
import { QueryParamsService } from '../../../../shared';
@UntilDestroy()
@Component({
templateUrl: 'api-keys.component.html',
styleUrls: ['api-keys.component.scss'],
providers: [ApiKeysExpandedIdManager, FetchApiKeysService],
})
export class ApiKeysComponent {
showInactive = this.qp.params.showInactive;
apiKeys$ = this.fetchApiKeysService.apiKeys$;
isLoading$ = this.fetchApiKeysService.isLoading$;
expandedId$ = this.apiKeysExpandedIdManager.expandedId$;
lastUpdated$ = this.fetchApiKeysService.lastUpdated$;
constructor(
private qp: QueryParamsService<{ showInactive: boolean }>,
private apiKeysExpandedIdManager: ApiKeysExpandedIdManager,
private fetchApiKeysService: FetchApiKeysService,
private dialogService: DialogService
) {}
update(params: Omit<ListApiKeysRequestParams, 'partyId'> = {}) {
this.fetchApiKeysService.update(Object.assign(params, !this.showInactive && { status: ApiKeyStatus.Active }));
}
create() {
this.dialogService
.open(ApiKeyCreateDialogComponent)
.afterClosed()
.pipe(untilDestroyed(this))
.subscribe(() => {
this.update();
});
}
toggle() {
this.showInactive = !this.showInactive;
void this.qp.set({ showInactive: this.showInactive });
this.update();
}
expandedIdChange(id: number) {
this.apiKeysExpandedIdManager.expandedIdChange(id);
}
}

View File

@ -0,0 +1,38 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { TranslocoModule } from '@ngneat/transloco';
import { ButtonModule } from '@dsh/components/buttons';
import { EmptySearchResultModule } from '@dsh/components/empty-search-result';
import { SpinnerModule } from '@dsh/components/indicators';
import { CardModule } from '@dsh/components/layout';
import { ApiKeysListModule } from './api-keys-list/api-keys-list.module';
import { ApiKeysRoutingModule } from './api-keys-routing.module';
import { ApiKeysComponent } from './api-keys.component';
import { ApiKeyCreateDialogComponent } from './components/api-key-create-dialog/api-key-create-dialog.component';
import { ApiKeyRevokeComponent } from './components/api-key-revoke/api-key-revoke.component';
@NgModule({
declarations: [ApiKeysComponent, ApiKeyRevokeComponent],
imports: [
ApiKeysRoutingModule,
FlexModule,
TranslocoModule,
CardModule,
MatInputModule,
CommonModule,
ButtonModule,
ClipboardModule,
EmptySearchResultModule,
SpinnerModule,
MatSlideToggleModule,
ApiKeysListModule,
ApiKeyCreateDialogComponent,
],
})
export class ApiKeysModule {}

View File

@ -0,0 +1,33 @@
<dsh-base-dialog
*transloco="let t; scope: 'payment-section'; read: 'paymentSection.apiKeys.createDialog'"
[title]="t('title')"
(cancel)="closeWithCancellation()"
>
<div *ngIf="apiKey; else releaseApiKey">
<mat-form-field style="width: 100%">
<mat-label>{{ t('apiKey') }}</mat-label>
<textarea matInput [value]="apiKey" readonly rows="5"></textarea>
</mat-form-field>
</div>
<ng-template #releaseApiKey>
<div fxLayout="column" fxLayoutGap="24px" [formGroup]="form">
<mat-form-field style="width: 100%">
<mat-label>{{ t('keyName') }}</mat-label>
<input matInput required formControlName="name" />
</mat-form-field>
<div class="dsh-body-1">
{{ t('desc') }}
</div>
</div>
</ng-template>
<ng-container dshBaseDialogActions>
<button *ngIf="apiKey; else confirmActions" dsh-button (click)="copy()" color="primary">
{{ t('copy') }}
</button>
<ng-template #confirmActions>
<button dsh-button (click)="confirm()" color="primary" [disabled]="form.invalid">
{{ t('confirm') }}
</button>
</ng-template>
</ng-container>
</dsh-base-dialog>

View File

@ -0,0 +1,76 @@
import { ClipboardModule, Clipboard } from '@angular/cdk/clipboard';
import { CommonModule } from '@angular/common';
import { Component, Injector } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { ReactiveFormsModule, NonNullableFormBuilder } from '@angular/forms';
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
import { TranslocoModule, TranslocoService } from '@ngneat/transloco';
import { untilDestroyed, UntilDestroy } from '@ngneat/until-destroy';
import { DialogSuperclass } from '@vality/ng-core';
import { ApiKeysService } from '@dsh/api/api-keys';
import { BaseDialogModule } from '@dsh/app/shared/components/dialog/base-dialog';
import { ErrorService, NotificationService } from '@dsh/app/shared/services';
import { ButtonModule } from '@dsh/components/buttons';
import { SpinnerModule } from '@dsh/components/indicators';
@UntilDestroy()
@Component({
selector: 'dsh-api-key-create-dialog',
standalone: true,
templateUrl: './api-key-create-dialog.component.html',
styles: [],
imports: [
BaseDialogModule,
SpinnerModule,
FlexModule,
ButtonModule,
TranslocoModule,
MatInputModule,
ReactiveFormsModule,
CommonModule,
ClipboardModule,
],
})
export class ApiKeyCreateDialogComponent extends DialogSuperclass<ApiKeyCreateDialogComponent> {
form = this.fb.group({ name: '' });
apiKey: string;
constructor(
injector: Injector,
private apiKeysService: ApiKeysService,
private errorService: ErrorService,
private notificationService: NotificationService,
private fb: NonNullableFormBuilder,
private clipboard: Clipboard,
private transloco: TranslocoService
) {
super(injector);
}
confirm() {
this.apiKeysService
.issueApiKey()
.pipe(untilDestroyed(this))
.subscribe({
next: (res) => {
this.apiKey = res.accessToken;
},
error: (err) => {
this.errorService.error(err);
},
});
}
copy() {
if (this.clipboard.copy(this.apiKey)) {
this.notificationService.success(
this.transloco.selectTranslate('apiKeys.copy.success', null, 'payment-section')
);
} else {
this.notificationService.error(
this.transloco.selectTranslate('apiKeys.copy.error', null, 'payment-section')
);
}
}
}

View File

@ -0,0 +1 @@
<div fxLayoutAlign="center center"><dsh-spinner></dsh-spinner></div>

View File

@ -0,0 +1,42 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslocoService } from '@ngneat/transloco';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { ApiKeysService } from '@dsh/api/api-keys';
import { ErrorService, NotificationService } from '@dsh/app/shared/services';
@UntilDestroy()
@Component({
selector: 'dsh-api-key-revoke',
templateUrl: './api-key-revoke.component.html',
})
export class ApiKeyRevokeComponent implements OnInit {
constructor(
private route: ActivatedRoute,
private apiKeysService: ApiKeysService,
private router: Router,
private notificationService: NotificationService,
private errorService: ErrorService,
private translocoService: TranslocoService
) {}
ngOnInit(): void {
const { apiKeyRevokeToken, apiKeyId } = this.route.snapshot.params as Record<string, string>;
this.apiKeysService
.revokeApiKey({ apiKeyRevokeToken, apiKeyId })
.pipe(untilDestroyed(this))
.subscribe({
next: () => {
this.notificationService.success(
this.translocoService.selectTranslate('apiKeys.revoke.success', null, 'payment-section')
);
void this.router.navigate(['../../..'], { relativeTo: this.route });
},
error: (err) => {
this.errorService.error(err);
void this.router.navigate(['../../..'], { relativeTo: this.route });
},
});
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { ListApiKeysRequestParams } from '@vality/swag-api-keys';
import { BehaviorSubject, Observable, defer, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { ApiKeysService } from '@dsh/api/api-keys';
import { ErrorService } from '@dsh/app/shared/services';
import { mapToTimestamp, shareReplayRefCount } from '@dsh/operators';
import { inProgressFrom, progressTo } from '@dsh/utils';
@Injectable()
export class FetchApiKeysService {
apiKeys$ = defer(() => this.fetchApiKeys$).pipe(
switchMap((p) =>
this.apiKeysService.listApiKeys(p).pipe(
map((r) => r.results),
progressTo(() => this.progress$),
catchError((err) => {
this.errorService.error(err);
return of([]);
})
)
),
shareReplayRefCount()
);
isLoading$ = inProgressFrom(() => this.progress$, this.apiKeys$);
lastUpdated$: Observable<string> = this.apiKeys$.pipe(mapToTimestamp, shareReplayRefCount());
private progress$ = new BehaviorSubject(0);
private fetchApiKeys$ = new BehaviorSubject<Omit<ListApiKeysRequestParams, 'partyId'>>({});
constructor(private apiKeysService: ApiKeysService, private errorService: ErrorService) {}
update(params: Omit<ListApiKeysRequestParams, 'partyId'> = {}) {
this.fetchApiKeys$.next(params);
}
}

View File

@ -0,0 +1 @@
export * from './api-keys.module';

View File

@ -4,6 +4,7 @@ import { RouterModule, Routes } from '@angular/router';
import { createPrivateRoute, RoleAccessName } from '@dsh/app/auth'; import { createPrivateRoute, RoleAccessName } from '@dsh/app/auth';
import { IntegrationsComponent } from './integrations.component'; import { IntegrationsComponent } from './integrations.component';
import { environment } from '../../../../environments';
const ROUTES: Routes = [ const ROUTES: Routes = [
{ {
@ -26,8 +27,10 @@ const ROUTES: Routes = [
), ),
createPrivateRoute( createPrivateRoute(
{ {
path: 'api-key', path: 'api-keys',
loadChildren: () => import('./api-key').then((m) => m.ApiKeyModule), loadChildren: environment.stage
? () => import('./api-keys').then((m) => m.ApiKeysModule)
: () => import('./api-key').then((m) => m.ApiKeyModule),
}, },
[RoleAccessName.ApiKeys] [RoleAccessName.ApiKeys]
), ),

View File

@ -1,6 +1,8 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { TranslocoService } from '@ngneat/transloco'; import { TranslocoService } from '@ngneat/transloco';
import { environment } from '../../../../environments';
@Component({ @Component({
templateUrl: 'integrations.component.html', templateUrl: 'integrations.component.html',
}) })
@ -11,8 +13,10 @@ export class IntegrationsComponent {
label$: this.transloco.selectTranslate('integrations.tabs.payment-link', null, 'payment-section'), label$: this.transloco.selectTranslate('integrations.tabs.payment-link', null, 'payment-section'),
}, },
{ {
path: 'api-key', path: 'api-keys',
label$: this.transloco.selectTranslate('integrations.tabs.api-key', null, 'payment-section'), label$: environment.stage
? this.transloco.selectTranslate('integrations.tabs.api-keys', null, 'payment-section')
: this.transloco.selectTranslate('integrations.tabs.api-key', null, 'payment-section'),
}, },
{ {
path: 'webhooks', path: 'webhooks',

View File

@ -6,6 +6,7 @@ import {
LegacySimpleSnackBar as SimpleSnackBar, LegacySimpleSnackBar as SimpleSnackBar,
} from '@angular/material/legacy-snack-bar'; } from '@angular/material/legacy-snack-bar';
import { TranslocoService } from '@ngneat/transloco'; import { TranslocoService } from '@ngneat/transloco';
import { Observable, first, isObservable, timeout } from 'rxjs';
const DEFAULT_DURATION_MS = 3000; const DEFAULT_DURATION_MS = 3000;
@ -14,21 +15,32 @@ export class NotificationService {
constructor(private snackBar: MatSnackBar, private transloco: TranslocoService) {} constructor(private snackBar: MatSnackBar, private transloco: TranslocoService) {}
success( success(
message: string = this.transloco.translate('notification.success', null, 'services') message: string | Observable<string> = this.transloco.translate('notification.success', null, 'services')
): MatSnackBarRef<SimpleSnackBar> { ): MatSnackBarRef<SimpleSnackBar> {
return this.openSnackBar(message); return this.openSnackBar(message);
} }
error( error(
message: string = this.transloco.translate('notification.error', null, 'services') message: string | Observable<string> = this.transloco.translate('notification.error', null, 'services')
): MatSnackBarRef<SimpleSnackBar> { ): MatSnackBarRef<SimpleSnackBar> {
return this.openSnackBar(message); return this.openSnackBar(message);
} }
private openSnackBar(message: string, config: MatSnackBarConfig<unknown> = {}): MatSnackBarRef<SimpleSnackBar> { private openSnackBar(
return this.snackBar.open(message, this.transloco.translate('notification.ok', null, 'services'), { message: string | Observable<string>,
config: MatSnackBarConfig<unknown> = {}
): MatSnackBarRef<SimpleSnackBar> {
const okMessage = this.transloco.translate('notification.ok', null, 'services');
const resConfig = {
duration: DEFAULT_DURATION_MS, duration: DEFAULT_DURATION_MS,
...config, ...config,
};
if (isObservable(message)) {
message.pipe(first(), timeout(5000)).subscribe((m) => {
this.snackBar.open(m, okMessage, resConfig);
}); });
return;
}
return this.snackBar.open(message, okMessage, resConfig);
} }
} }

View File

@ -18,6 +18,38 @@
"description": "Your private key to access the", "description": "Your private key to access the",
"title": "API key" "title": "API key"
}, },
"apiKeys": {
"copy": {
"error": "An error occurred while copying",
"success": "Successfully copied"
},
"create": "Release key",
"createDialog": {
"apiKey": "Private API key",
"confirm": "Release key",
"copy": "Copy",
"desc": "Private API key will be available only once, at the moment of its issuance. Therefore, it is necessary to copy the key and save it in a secure location after receiving it.",
"keyName": "Key name",
"title": "Release a new key"
},
"deleteDialog": {
"confirm": "Request key revocation",
"desc": "You will receive an email with a confirmation link. Please follow the link to confirm the operation. After revoking the key, it will no longer be valid, and if you need access to the API in the future, you will have to create a new one.",
"title": "Key revocation request"
},
"emptyResult": "No keys released",
"list": {
"createdAt": "Created at",
"delete": "Revoke key",
"details": "Key data",
"name": "Name",
"status": "Status"
},
"revoke": {
"success": "Key successfully revoked"
},
"showInactive": "Show inactive"
},
"balances": { "balances": {
"title": "Available amount" "title": "Available amount"
}, },
@ -76,6 +108,7 @@
"integrations": { "integrations": {
"tabs": { "tabs": {
"api-key": "API KEY", "api-key": "API KEY",
"api-keys": "API KEYS",
"payment-link": "PAYMENT LINK", "payment-link": "PAYMENT LINK",
"webhooks": "WEBHOOKS" "webhooks": "WEBHOOKS"
} }
@ -146,8 +179,8 @@
"shops": "Shops" "shops": "Shops"
}, },
"table": { "table": {
"id": "ID",
"amount": "Amount", "amount": "Amount",
"id": "ID",
"shop": "Shop", "shop": "Shop",
"status": "Status", "status": "Status",
"statusChanged": "Last modified" "statusChanged": "Last modified"

View File

@ -18,6 +18,38 @@
"description": "Ваш приватный ключ для доступа к", "description": "Ваш приватный ключ для доступа к",
"title": "API ключ" "title": "API ключ"
}, },
"apiKeys": {
"copy": {
"error": "Ошибка копирования",
"success": "Успешно скопировано"
},
"create": "Выпустить ключ",
"createDialog": {
"apiKey": "Приватный API ключ",
"confirm": "Выпустить ключ",
"copy": "Скопировать",
"desc": "Приватный API ключ будет доступен только один раз, в момент его выпуска. Поэтому необходимо скопировать ключ и сохранить его в надежном месте после получения.",
"keyName": "Название ключа",
"title": "Выпустить новый ключ"
},
"deleteDialog": {
"confirm": "Запросить отзыв ключа",
"desc": "На вашу электронную почту будет отправлено письмо со ссылкой. Пожалуйста, перейдите по этой ссылке, чтобы подтвердить операцию. После отзыва ключ больше не будет действительным, и если вам потребуется доступ к API в будущем, вам придется создать новый ключ.",
"title": "Запрос на отзыв ключа"
},
"emptyResult": "Не выпущено ни одного ключа",
"list": {
"createdAt": "Создан",
"delete": "Удалить",
"details": "Данные ключа",
"name": "Название",
"status": "Статус"
},
"revoke": {
"success": "Ключ успешно отозван"
},
"showInactive": "Показывать неактивные"
},
"balances": { "balances": {
"title": "Доступные средства" "title": "Доступные средства"
}, },
@ -76,6 +108,7 @@
"integrations": { "integrations": {
"tabs": { "tabs": {
"api-key": "API КЛЮЧ", "api-key": "API КЛЮЧ",
"api-keys": "API КЛЮЧИ",
"payment-link": "ПЛАТЕЖНАЯ ССЫЛКА", "payment-link": "ПЛАТЕЖНАЯ ССЫЛКА",
"webhooks": "WEBHOOKS" "webhooks": "WEBHOOKS"
} }
@ -146,8 +179,8 @@
"shops": "Магазины" "shops": "Магазины"
}, },
"table": { "table": {
"id": "ID",
"amount": "Сумма списания", "amount": "Сумма списания",
"id": "ID",
"shop": "Магазин", "shop": "Магазин",
"status": "Статус", "status": "Статус",
"statusChanged": "Статус изменен" "statusChanged": "Статус изменен"

View File

@ -0,0 +1,9 @@
import { Environment } from './types/environment';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const DEFAULT_ENVIRONMENT: Environment = {
production: false,
stage: window.location.host.split('.')[0] === 'stage',
appConfigPath: '/appConfig.json',
authConfigPath: '/authConfig.json',
};

View File

@ -1,8 +0,0 @@
import { environment as prodEnvironment } from './environment.prod';
import { Environment } from './types/environment';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const environment: Environment = {
...prodEnvironment,
production: false,
};

View File

@ -1,8 +1,8 @@
import { DEFAULT_ENVIRONMENT } from './default-environment';
import { Environment } from './types/environment'; import { Environment } from './types/environment';
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
export const environment: Environment = { export const environment: Environment = {
...DEFAULT_ENVIRONMENT,
production: true, production: true,
appConfigPath: '/appConfig.json',
authConfigPath: '/authConfig.json',
}; };

View File

@ -1,9 +1,10 @@
import { environment as devEnvironment } from './environment.dev'; import { DEFAULT_ENVIRONMENT } from './default-environment';
import { Environment } from './types/environment'; import { Environment } from './types/environment';
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
export const environment: Environment = { export const environment: Environment = {
...devEnvironment, ...DEFAULT_ENVIRONMENT,
appConfigPath: '/appConfig.stage.json', appConfigPath: '/appConfig.stage.json',
authConfigPath: '/authConfig.stage.json', authConfigPath: '/authConfig.stage.json',
stage: true,
}; };

View File

@ -1,4 +1,4 @@
import { environment as devEnvironment } from './environment.dev'; import { DEFAULT_ENVIRONMENT } from './default-environment';
import { Environment } from './types/environment'; import { Environment } from './types/environment';
// This file can be replaced during build by using the `fileReplacements` array. // This file can be replaced during build by using the `fileReplacements` array.
@ -6,7 +6,7 @@ import { Environment } from './types/environment';
// The list of file replacements can be found in `angular.json`. // The list of file replacements can be found in `angular.json`.
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
export const environment: Environment = devEnvironment; export const environment: Environment = DEFAULT_ENVIRONMENT;
/* /*
* For easier debugging in development mode, you can import the following file * For easier debugging in development mode, you can import the following file

View File

@ -2,4 +2,5 @@ export interface Environment {
production: boolean; production: boolean;
appConfigPath: string; appConfigPath: string;
authConfigPath: string; authConfigPath: string;
stage: boolean;
} }

View File

@ -6,7 +6,8 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
.mat-dialog-container { .mat-dialog-container,
.mat-mdc-dialog-surface {
border-radius: 0 !important; border-radius: 0 !important;
} }
} }

View File

@ -1,5 +1,6 @@
@mixin mat-dialog-override() { @mixin mat-dialog-override() {
.mat-dialog-container { .mat-dialog-container,
.mat-mdc-dialog-surface {
box-shadow: none !important; box-shadow: none !important;
padding: 24px !important; padding: 24px !important;
border-radius: 8px !important; border-radius: 8px !important;