agmission/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.ts

311 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}
}