311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
import { Component, OnInit, OnDestroy, OnChanges, SimpleChanges, Input, ViewChild } from '@angular/core';
|
||
import { Observable, Subject, BehaviorSubject, combineLatest } from 'rxjs';
|
||
import { takeUntil, map } from 'rxjs/operators';
|
||
import { Table } from 'primeng/table';
|
||
|
||
import { ApiKey, CreateApiKeyResponse } from '../models/api-key.model';
|
||
import { ApiKeyState, FEATURE_KEY } from '../../reducers';
|
||
import * as ApiKeyActions from '../../actions/api-key.actions';
|
||
import { BaseComp } from '@app/shared/base/base.component';
|
||
import { RoleIds } from '@app/shared/global';
|
||
import { CustomerService } from '@app/domain/services/customer.service';
|
||
import { FilterDefinition, FilterChangeEvent, ActiveFilter } from '@app/shared/dynamic-filter/dynamic-filter.component';
|
||
|
||
@Component({
|
||
selector: 'agm-api-key-manager',
|
||
templateUrl: './api-key-manager.component.html',
|
||
styleUrls: ['./api-key-manager.component.css'],
|
||
})
|
||
export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestroy, OnChanges {
|
||
@Input() ownerId: string;
|
||
@Input() toggleable = false;
|
||
@Input() collapsed = false;
|
||
@ViewChild('dt') dt!: Table;
|
||
keys$: Observable<ApiKey[]>;
|
||
filteredKeys$: Observable<ApiKey[]>;
|
||
loading$: Observable<boolean>;
|
||
error$: Observable<string | null>;
|
||
|
||
filterDefinitions: FilterDefinition[] = [];
|
||
filterAccordionOpen = sessionStorage.getItem('api-key-filter-accordion') === 'true';
|
||
private readonly activeFilters$ = new BehaviorSubject<ActiveFilter[]>([]);
|
||
|
||
newKey: CreateApiKeyResponse | null = null;
|
||
newKeyOwnerLabel: string | null = null;
|
||
newKeyLabel = '';
|
||
newService = 'data_export';
|
||
newKeyOwnerId: string | null = null;
|
||
keyCopied = false;
|
||
isAdmin = false;
|
||
isMasterAccount = false;
|
||
customerOptions: { label: string; value: string }[] = [];
|
||
|
||
private destroy$ = new Subject<void>();
|
||
|
||
cols: any[] = [];
|
||
expandedRows: { [id: string]: boolean } = {};
|
||
selectedKey: ApiKey | null = null;
|
||
showNewDialog = false;
|
||
|
||
createdAtFilter: Date | null = null;
|
||
lastUsedAtFilter: Date | null = null;
|
||
|
||
statusOptions = [
|
||
{ label: $localize`:@@all:All`, value: null },
|
||
{ label: $localize`:@@active:Active`, value: true },
|
||
{ label: $localize`:@@revoked:Revoked`, value: false },
|
||
];
|
||
|
||
serviceOptions = [
|
||
{ label: $localize`:@@dataExportApi:Data Export API`, value: 'data_export' },
|
||
{ label: $localize`:@@partnerApi:Partner API`, value: 'partner_api' },
|
||
];
|
||
|
||
readonly serviceLabels: Record<string, string> = {
|
||
data_export: $localize`:@@dataExportApi:Data Export API`,
|
||
partner_api: $localize`:@@partnerApi:Partner API`,
|
||
};
|
||
|
||
constructor(private readonly customerSvc: CustomerService) {
|
||
super();
|
||
this.keys$ = this.store.select((s: any) => s[FEATURE_KEY].keys);
|
||
this.loading$ = this.store.select((s: any) => s[FEATURE_KEY].loading);
|
||
this.error$ = this.store.select((s: any) => s[FEATURE_KEY].error);
|
||
}
|
||
|
||
ngOnInit(): void {
|
||
this.filteredKeys$ = combineLatest([
|
||
this.keys$.pipe(map(k => k || [])),
|
||
this.activeFilters$,
|
||
]).pipe(
|
||
map(([keys, filters]) => this.applyFilters(keys, filters))
|
||
);
|
||
|
||
this.isAdmin = this.authSvc.hasRole([RoleIds.ADMIN]);
|
||
this.isMasterAccount = this.authSvc.hasRole([RoleIds.APP]);
|
||
|
||
const serviceFilterOptions = [
|
||
{ label: $localize`:@@all:All`, value: null },
|
||
...this.serviceOptions.map(o => ({ label: o.label, value: o.value })),
|
||
];
|
||
const statusFilterOptions = [
|
||
{ label: $localize`:@@all:All`, value: null },
|
||
{ label: $localize`:@@active:Active`, value: true },
|
||
{ label: $localize`:@@revoked:Revoked`, value: false },
|
||
];
|
||
|
||
this.filterDefinitions = [
|
||
{ key: 'label', label: $localize`:@@label:Label`, dataType: 'text' },
|
||
{ key: 'prefix', label: $localize`:@@prefix:Prefix`, dataType: 'text' },
|
||
{ key: 'service', label: $localize`:@@service:Service`, dataType: 'select', options: serviceFilterOptions },
|
||
{ key: 'active', label: $localize`:@@status:Status`, dataType: 'select', options: statusFilterOptions },
|
||
{ key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date' },
|
||
{ key: 'lastUsedAt', label: $localize`:@@lastUsed:Last Used`, dataType: 'date' },
|
||
{ key: 'requestCount', label: $localize`:@@requests:Requests`, dataType: 'number' },
|
||
];
|
||
|
||
if (this.isAdmin && !this.ownerId) {
|
||
this.filterDefinitions = [
|
||
{ key: 'owner.name', label: $localize`:@@name:Name`, dataType: 'text' },
|
||
{ key: 'owner.username', label: $localize`:@@userName:Username`, dataType: 'text' },
|
||
{ key: 'owner.contact', label: $localize`:@@contact:Contact`, dataType: 'text' },
|
||
...this.filterDefinitions,
|
||
];
|
||
}
|
||
|
||
this.cols = [
|
||
{ field: 'label', header: $localize`:@@label:Label`, filtered: true, filterMatchMode: 'contains' },
|
||
{ field: 'prefix', header: $localize`:@@prefix:Prefix`, filtered: true, filterMatchMode: 'contains' },
|
||
{ field: 'service', header: $localize`:@@service:Service`, filtered: true, filterMatchMode: 'contains' },
|
||
];
|
||
|
||
if (this.isAdmin && !this.ownerId) {
|
||
this.cols = [
|
||
{ field: 'owner.name', header: $localize`:@@name:Name`, filtered: true, filterMatchMode: 'contains' },
|
||
{ field: 'owner.username', header: $localize`:@@userName:Username`, filtered: true, filterMatchMode: 'contains' },
|
||
{ field: 'owner.contact', header: $localize`:@@contact:Contact`, filtered: true, filterMatchMode: 'contains' },
|
||
...this.cols,
|
||
{ field: 'active', header: $localize`:@@status:Status` },
|
||
];
|
||
}
|
||
|
||
this.store.dispatch(ApiKeyActions.loadApiKeys({ ownerId: this.ownerId }));
|
||
|
||
if (this.isAdmin && !this.ownerId) {
|
||
this.customerSvc.loadCustomers().pipe(takeUntil(this.destroy$)).subscribe(customers => {
|
||
this.customerOptions = customers.map(c => ({ label: c.username || c.name || c._id, value: c._id }));
|
||
});
|
||
}
|
||
|
||
this.store.select((s: any) => s[FEATURE_KEY].newKey)
|
||
.pipe(takeUntil(this.destroy$))
|
||
.subscribe(key => {
|
||
this.newKey = key;
|
||
if (key) { this.showNewDialog = false; }
|
||
});
|
||
}
|
||
|
||
ngOnChanges(changes: SimpleChanges): void {
|
||
if (changes.ownerId && !changes.ownerId.firstChange) {
|
||
this.store.dispatch(ApiKeyActions.dismissNewKey());
|
||
this.store.dispatch(ApiKeyActions.loadApiKeys({ ownerId: this.ownerId }));
|
||
}
|
||
}
|
||
|
||
ngOnDestroy(): void {
|
||
this.store.dispatch(ApiKeyActions.dismissNewKey());
|
||
this.activeFilters$.complete();
|
||
this.destroy$.next();
|
||
this.destroy$.complete();
|
||
}
|
||
|
||
openNewDialog(): void {
|
||
this.newKeyLabel = '';
|
||
this.newService = 'data_export';
|
||
this.newKeyOwnerId = null;
|
||
this.showNewDialog = true;
|
||
}
|
||
|
||
submitCreate(): void {
|
||
const label = this.newKeyLabel.trim();
|
||
if (!label) { return; }
|
||
const effectiveOwnerId = this.ownerId || (this.isAdmin ? this.newKeyOwnerId : null);
|
||
if (this.isAdmin && !this.ownerId && !effectiveOwnerId) { return; }
|
||
const request: any = { label, service: this.newService };
|
||
if (effectiveOwnerId) { request.ownerId = effectiveOwnerId; }
|
||
const ownerOpt = this.customerOptions.find(o => o.value === effectiveOwnerId);
|
||
this.newKeyOwnerLabel = ownerOpt ? ownerOpt.label : null;
|
||
this.store.dispatch(ApiKeyActions.createApiKey({ request }));
|
||
this.newKeyLabel = '';
|
||
this.newService = 'data_export';
|
||
this.newKeyOwnerId = null;
|
||
}
|
||
|
||
dismissNewKey(): void {
|
||
this.newKey = null;
|
||
this.newKeyOwnerLabel = null;
|
||
this.store.dispatch(ApiKeyActions.dismissNewKey());
|
||
this.keyCopied = false;
|
||
}
|
||
|
||
copyKey(): void {
|
||
if (!this.newKey?.key) { return; }
|
||
navigator.clipboard.writeText(this.newKey.key).then(() => {
|
||
this.keyCopied = true;
|
||
});
|
||
}
|
||
|
||
confirmRegenerate(key: ApiKey): void {
|
||
this.confirmSvc.confirm({
|
||
message: $localize`:@@regenerateKeyConfirm:Regenerate the key "${key.label}"? The old key will stop working immediately.`,
|
||
header: $localize`:@@regenerateKey:Regenerate Key`,
|
||
icon: 'ui-icon-refresh',
|
||
accept: () => {
|
||
this.store.dispatch(ApiKeyActions.regenerateApiKey({ keyId: key._id, ownerId: this.ownerId }));
|
||
}
|
||
});
|
||
}
|
||
|
||
confirmRevoke(key: ApiKey): void {
|
||
this.confirmSvc.confirm({
|
||
message: $localize`:@@revokeKeyConfirm:Revoke the key "${key.label}"? This cannot be undone.`,
|
||
header: $localize`:@@revokeKey:Revoke Key`,
|
||
icon: 'pi pi-exclamation-triangle',
|
||
accept: () => {
|
||
this.store.dispatch(ApiKeyActions.revokeApiKey({ keyId: key._id, ownerId: this.ownerId }));
|
||
}
|
||
});
|
||
}
|
||
|
||
confirmDelete(key: ApiKey): void {
|
||
this.confirmSvc.confirm({
|
||
message: $localize`:@@deleteKeyConfirm:Permanently delete the key "${key.label}"? This action cannot be undone.`,
|
||
header: $localize`:@@deleteKey:Delete Key`,
|
||
icon: 'pi pi-trash',
|
||
accept: () => {
|
||
this.store.dispatch(ApiKeyActions.deleteApiKey({ keyId: key._id, ownerId: this.ownerId }));
|
||
}
|
||
});
|
||
}
|
||
|
||
onFiltersChanged(event: FilterChangeEvent): void {
|
||
// Apply immediately only when a filter is removed or all are cleared
|
||
if (event.filters.length < this.activeFilters$.value.length) {
|
||
this.activeFilters$.next(event.filters);
|
||
}
|
||
}
|
||
|
||
onFiltersSubmit(event: FilterChangeEvent): void {
|
||
this.activeFilters$.next(event.filters);
|
||
}
|
||
|
||
onAccordionToggle(expanded: boolean): void {
|
||
sessionStorage.setItem('api-key-filter-accordion', String(expanded));
|
||
}
|
||
|
||
onDateFilter(value: Date, field: string): void {
|
||
this.dt.filter(value, field, 'dateIs');
|
||
}
|
||
|
||
private applyFilters(keys: ApiKey[], filters: ActiveFilter[]): ApiKey[] {
|
||
if (!filters.length) { return keys; }
|
||
return keys.filter(key => {
|
||
// Left-to-right operator evaluation, matching server buildDynamicFilter logic:
|
||
// filter[i].operator describes how filter[i] combines with the accumulated result.
|
||
// e.g. A(and) B(and) C(or) D(and) → ((A ∧ B) ∨ C) ∧ D
|
||
let result = this.matchesFilter(key, filters[0]);
|
||
for (let i = 1; i < filters.length; i++) {
|
||
const match = this.matchesFilter(key, filters[i]);
|
||
result = filters[i].operator === 'or' ? result || match : result && match;
|
||
}
|
||
return result;
|
||
});
|
||
}
|
||
|
||
private resolveField(obj: any, path: string): any {
|
||
return path.split('.').reduce((cur, part) => (cur != null ? cur[part] : undefined), obj);
|
||
}
|
||
|
||
private matchesFilter(key: ApiKey, filter: ActiveFilter): boolean {
|
||
const val = this.resolveField(key, filter.definition.key);
|
||
const fval = filter.value;
|
||
|
||
if (fval == null || fval === '') { return true; }
|
||
|
||
switch (filter.definition.dataType) {
|
||
case 'text': {
|
||
const s = String(val ?? '').toLowerCase();
|
||
const q = String(fval).toLowerCase();
|
||
switch (filter.valueOperator) {
|
||
case 'startsWith': return s.startsWith(q);
|
||
case 'exact': return s === q;
|
||
default: return s.includes(q);
|
||
}
|
||
}
|
||
case 'select':
|
||
return val === fval;
|
||
case 'date': {
|
||
if (!val) { return false; }
|
||
const d = new Date(val).setHours(0, 0, 0, 0);
|
||
const fd = new Date(fval).setHours(0, 0, 0, 0);
|
||
switch (filter.valueOperator) {
|
||
case 'before': return d < fd;
|
||
case 'after': return d > fd;
|
||
default: return d === fd;
|
||
}
|
||
}
|
||
case 'number': {
|
||
const n = Number(val ?? 0);
|
||
const fn = Number(fval);
|
||
switch (filter.valueOperator) {
|
||
case 'greaterThan': return n > fn;
|
||
case 'lessThan': return n < fn;
|
||
default: return n === fn;
|
||
}
|
||
}
|
||
default:
|
||
return true;
|
||
}
|
||
}
|
||
}
|