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; filteredKeys$: Observable; loading$: Observable; error$: Observable; filterDefinitions: FilterDefinition[] = []; filterAccordionOpen = sessionStorage.getItem('api-key-filter-accordion') === 'true'; private readonly activeFilters$ = new BehaviorSubject([]); 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(); 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 = { 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; } } }