Skip to content

Commit a574169

Browse files
committed
feat(api-keys): add API key management component with modal for creation and editing
Signed-off-by: Manuel Abascal <[email protected]>
1 parent 77353fe commit a574169

File tree

13 files changed

+911
-1
lines changed

13 files changed

+911
-1
lines changed
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<div class="card w-100 h-100 m-h-0">
2+
<div class="card-header d-flex justify-content-between align-items-center">
3+
<div class="mb-0 d-flex flex-column">
4+
<h6 class="card-title label-header text-uppercase">
5+
API Keys
6+
</h6>
7+
<span class="text-gray-500 mt-1 fw-semibold fs-6">
8+
The API key is a simple encrypted string that identifies you in the application. With this key, you can access the REST API.
9+
</span>
10+
</div>
11+
<button (click)="openCreateModal()"
12+
class="btn utm-button utm-button-primary">
13+
<i class="icon-key mr-2"></i> Create New API Key
14+
</button>
15+
</div>
16+
<div class="card-body">
17+
<div class="table-responsive resizable-table-responsive m-h-0 mt-3">
18+
<table class="table table text-nowrap">
19+
<thead>
20+
<tr>
21+
<th (sort)="onSortBy($event)"
22+
[sortable]="'name'" appColumnSortable
23+
class="font-weight-semibold cursor-pointer"
24+
scope="col">
25+
Name
26+
</th>
27+
<th (sort)="onSortBy($event)"
28+
[sortable]="'allowedIp'" appColumnSortable
29+
class="font-weight-semibold cursor-pointer"
30+
scope="col">
31+
Allowed IPs
32+
</th>
33+
<th (sort)="onSortBy($event)"
34+
[sortable]="'expiresAt'" appColumnSortable
35+
class="font-weight-semibold cursor-pointer"
36+
scope="col">
37+
Expires At
38+
</th>
39+
<th (sort)="onSortBy($event)"
40+
[sortable]="'createdAt'" appColumnSortable
41+
class="font-weight-semibold cursor-pointer"
42+
scope="col">
43+
Created At
44+
</th>
45+
<th class="text-center">ACTIONS</th>
46+
</tr>
47+
</thead>
48+
<tbody *ngIf="!loading && apiKeys.length;">
49+
<tr *ngFor="let key of apiKeys">
50+
<td><i class="fa fa-info-circle text-warning mr-2 cursor-pointer"></i> {{ key.name }}</td>
51+
<td>{{ key.allowedIp?.join(', ') || '—' }}</td>
52+
<td>
53+
<ng-container *ngIf="getDaysUntilExpire(key.expiresAt) >= 0 && getDaysUntilExpire(key.expiresAt) <= 7">
54+
<span class="text-warning" [ngbTooltip]="'This key will expire soon'">
55+
<i class="fa fa-info-circle text-warning mr-2 cursor-pointer"></i>
56+
</span>
57+
</ng-container>
58+
59+
<ng-container *ngIf="getDaysUntilExpire(key.expiresAt) < 0">
60+
<span class="text-danger" [ngbTooltip]="'This key has expired'">
61+
<i class="fa fa-exclamation-circle text-danger mr-2 cursor-pointer"></i>
62+
</span>
63+
</ng-container>
64+
65+
{{ key.expiresAt ? (key.expiresAt | date:'dd/MM/yy HH:mm':'UTC') : '—' }}
66+
</td>
67+
68+
<td>{{ key.createdAt | date:'dd/MM/yy HH:mm' :'UTC' }}</td>
69+
<td class="text-center">
70+
<button class="btn bg-light m-0 p-1 cursor-pointer medium-icon mr-2" (click)="generateKey(key)">
71+
<i class="icon-sync"></i>
72+
</button>
73+
<button class="btn bg-light m-0 p-1 cursor-pointer medium-icon mr-2" (click)="editKey(key)">
74+
<i class="icon-pencil3"></i>
75+
</button>
76+
<button class="btn bg-light m-0 p-1 cursor-pointer medium-icon" (click)="deleteKey(key)">
77+
<i class="icon-cross2"></i>
78+
</button>
79+
</td>
80+
</tr>
81+
<ng-container *ngIf="!loading && apiKeys.length === 0">
82+
<tr>
83+
<td [attr.colspan]="5">
84+
<app-no-data-found></app-no-data-found>
85+
</td>
86+
</tr>
87+
</ng-container>
88+
89+
<ng-container *ngIf="loading">
90+
<tr>
91+
<td [attr.colspan]="5">
92+
<div class="p-5 d-flex justify-content-center align-items-center text-blue-800">
93+
<app-utm-spinner [height]="'35px'"
94+
[label]="'Loading...'"
95+
[loading]="loading"
96+
[width]="'35px'">
97+
</app-utm-spinner>
98+
</div>
99+
</td>
100+
</tr>
101+
</ng-container>
102+
</tbody>
103+
</table>
104+
</div>
105+
106+
<div *ngIf="!noData" class="mb-3 mt-3">
107+
<div class="row justify-content-center">
108+
<ngb-pagination (pageChange)="loadPage($event)"
109+
[boundaryLinks]="true"
110+
[collectionSize]="totalItems"
111+
[maxSize]="10"
112+
[pageSize]="itemsPerPage"
113+
[rotate]="true"
114+
[size]="'sm'">
115+
</ngb-pagination>
116+
<app-utm-items-per-page (itemsInPage)="onItemsPerPageChange($event)" class="ml-3">
117+
</app-utm-items-per-page>
118+
</div>
119+
<!-- TABLE END-->
120+
</div>
121+
</div>
122+
</div>
123+
124+
<ng-template #generatedModal let-modal>
125+
<app-utm-modal-header [name]="'Generated API Key'"
126+
[showCloseButton]="false">
127+
</app-utm-modal-header>
128+
129+
<div class="container-fluid p-3 d-flex flex-column">
130+
<h6 class="card-title label-header text-uppercase">
131+
Copy it now because it will be shown only once!
132+
</h6>
133+
<div class="d-flex justify-content-center input-group mt-3">
134+
<code class="fs-3 pt-1 w-75">{{ maskSecrets(generatedApiKey) }}</code>
135+
<button class="btn btn utm-button utm-button-primary" type="button" (click)="copyToClipboard()">
136+
<i [ngClass]="!copied ? 'icon-copy2' : 'icon-check2'"></i>
137+
{{ copied ? 'copied' : 'copy' }}
138+
</button>
139+
</div>
140+
<div class="d-flex align-items-center pt-1" *ngIf="generatedApiKey">
141+
<i class="icon-info22 fs-2 pl-4 text-warning mr-2"></i>
142+
<span class="text-warning">{{ 'Keep this key safeKeep this key safe' }}</span>
143+
</div>
144+
</div>
145+
<div class="d-flex justify-content-center my-3">
146+
<button class="btn btn utm-button utm-button-primary me-3" (click)="close()"
147+
[disabled]="!copied">
148+
{{ 'Close' }}
149+
</button>
150+
</div>
151+
</ng-template>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
:host{
2+
display: flex;
3+
flex-direction: column;
4+
flex: 1 1 auto;
5+
min-height: 0;
6+
height: 100%;
7+
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import {Component, OnInit, TemplateRef, ViewChild} from '@angular/core';
2+
import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap';
3+
import * as moment from 'moment';
4+
import {UtmToastService} from '../../shared/alert/utm-toast.service';
5+
import {
6+
ModalConfirmationComponent
7+
} from '../../shared/components/utm/util/modal-confirmation/modal-confirmation.component';
8+
import {ITEMS_PER_PAGE} from '../../shared/constants/pagination.constants';
9+
import {SortEvent} from '../../shared/directives/sortable/type/sort-event';
10+
import {ApiKeyModalComponent} from './shared/components/api-key-modal/api-key-modal.component';
11+
import {ApiKeyResponse} from './shared/models/ApiKeyResponse';
12+
import {ApiKeysService} from './shared/service/api-keys.service';
13+
14+
@Component({
15+
selector: 'app-api-keys',
16+
templateUrl: './api-keys.component.html',
17+
styleUrls: ['./api-keys.component.scss']
18+
})
19+
export class ApiKeysComponent implements OnInit {
20+
21+
generating: string[] = [];
22+
noData = false;
23+
apiKeys: ApiKeyResponse[] = [];
24+
loading = false;
25+
generatedApiKey = '';
26+
@ViewChild('generatedModal') generatedModal!: TemplateRef<any>;
27+
generatedModalRef!: NgbModalRef;
28+
copied = false;
29+
readonly itemsPerPage = ITEMS_PER_PAGE;
30+
totalItems = 0;
31+
page = 0;
32+
size = this.itemsPerPage;
33+
34+
request = {
35+
sort: 'createdAt,desc',
36+
page: this.page,
37+
size: this.size
38+
};
39+
40+
constructor( private toastService: UtmToastService,
41+
private apiKeyService: ApiKeysService,
42+
private modalService: NgbModal
43+
) {}
44+
45+
ngOnInit(): void {
46+
this.loadKeys();
47+
}
48+
49+
loadKeys(): void {
50+
this.loading = true;
51+
this.apiKeyService.list(this.request).subscribe({
52+
next: (res) => {
53+
this.totalItems = Number(res.headers.get('X-Total-Count'));
54+
this.apiKeys = res.body || [];
55+
this.noData = this.apiKeys.length === 0;
56+
this.loading = false;
57+
},
58+
error: () => {
59+
this.loading = false;
60+
this.apiKeys = [];
61+
}
62+
});
63+
}
64+
65+
copyToClipboard(): void {
66+
if (!this.generatedApiKey) { return; }
67+
68+
if (navigator && (navigator as any).clipboard && (navigator as any).clipboard.writeText) {
69+
(navigator as any).clipboard.writeText(this.generatedApiKey)
70+
.then(() => this.copied = true)
71+
.catch(err => {
72+
console.error('Error al copiar con clipboard API', err);
73+
this.fallbackCopy(this.generatedApiKey);
74+
});
75+
} else {
76+
this.fallbackCopy(this.generatedApiKey);
77+
}
78+
}
79+
80+
private fallbackCopy(text: string): void {
81+
try {
82+
const textarea = document.createElement('textarea');
83+
textarea.value = text;
84+
85+
textarea.style.position = 'fixed';
86+
textarea.style.top = '0';
87+
textarea.style.left = '0';
88+
textarea.style.opacity = '0';
89+
90+
document.body.appendChild(textarea);
91+
textarea.focus();
92+
textarea.select();
93+
94+
const successful = document.execCommand('copy');
95+
document.body.removeChild(textarea);
96+
97+
if (successful) {
98+
this.showCopiedFeedback();
99+
} else {
100+
console.warn('Fallback copy failed');
101+
}
102+
} catch (err) {
103+
console.error('Error en fallback copy', err);
104+
}
105+
}
106+
107+
private showCopiedFeedback(): void {
108+
this.copied = true;
109+
setTimeout(() => this.copied = false, 2000);
110+
}
111+
112+
openCreateModal(): void {
113+
const modalRef = this.modalService.open(ApiKeyModalComponent, { centered: true });
114+
115+
modalRef.result.then((key: ApiKeyResponse) => {
116+
if (key) {
117+
this.generateKey(key);
118+
}
119+
});
120+
}
121+
122+
editKey(key: ApiKeyResponse): void {
123+
const modalRef = this.modalService.open(ApiKeyModalComponent, {centered: true});
124+
modalRef.componentInstance.apiKey = key;
125+
126+
modalRef.result.then((key: ApiKeyResponse) => {
127+
if (key) {
128+
this.generateKey(key);
129+
}
130+
});
131+
}
132+
133+
deleteKey(apiKey: ApiKeyResponse): void {
134+
const modalRef = this.modalService.open(ModalConfirmationComponent, {centered: true});
135+
modalRef.componentInstance.header = `Delete API Key: ${apiKey.name}`;
136+
modalRef.componentInstance.message = 'Are you sure you want to delete this API key?';
137+
modalRef.componentInstance.confirmBtnType = 'delete';
138+
modalRef.componentInstance.type = 'danger';
139+
modalRef.componentInstance.confirmBtnText = 'Delete';
140+
modalRef.componentInstance.confirmBtnIcon = 'icon-cross-circle';
141+
142+
modalRef.result.then(reason => {
143+
if (reason === 'ok') {
144+
this.delete(apiKey);
145+
}
146+
});
147+
}
148+
149+
delete(apiKey: ApiKeyResponse): void {
150+
this.apiKeyService.delete(apiKey.id).subscribe({
151+
next: () => {
152+
this.toastService.showSuccess('API key deleted successfully.');
153+
this.loadKeys();
154+
},
155+
error: (err) => {
156+
this.toastService.showError('Error', 'An error occurred while deleting the API key.');
157+
throw err;
158+
}
159+
});
160+
}
161+
162+
getDaysUntilExpire(expiresAt: string): number {
163+
if (!expiresAt) {
164+
return -1;
165+
}
166+
167+
const today = moment().startOf('day');
168+
const expireDate = moment(expiresAt).startOf('day');
169+
return expireDate.diff(today, 'days');
170+
}
171+
172+
onSortBy($event: SortEvent) {
173+
this.request.sort = $event.column + ',' + $event.direction;
174+
this.loadKeys();
175+
}
176+
177+
maskSecrets(str: string): string {
178+
if (!str || str.length <= 10) {
179+
return str;
180+
}
181+
const prefix = str.substring(0, 10);
182+
const maskLength = str.length - 30;
183+
const maskedPart = '*'.repeat(maskLength);
184+
return prefix + maskedPart;
185+
}
186+
187+
generateKey(apiKey: ApiKeyResponse): void {
188+
this.generating.push(apiKey.id);
189+
this.apiKeyService.generateApiKey(apiKey.id).subscribe(response => {
190+
this.generatedApiKey = response.body ? response.body : "";
191+
this.generatedModalRef = this.modalService.open(this.generatedModal, {centered: true});
192+
const index = this.generating.indexOf(apiKey.id);
193+
if (index > -1) {
194+
this.generating.splice(index, 1);
195+
}
196+
this.loadKeys();
197+
});
198+
}
199+
200+
isApiKeyExpired(expiresAt?: string | null ): boolean {
201+
if (!expiresAt) {
202+
return false;
203+
}
204+
const expirationTime = new Date(expiresAt).getTime();
205+
return expirationTime < Date.now();
206+
}
207+
208+
close() {
209+
this.generatedModalRef.close();
210+
this.copied = false;
211+
this.generatedApiKey = '';
212+
}
213+
214+
loadPage($event: number) {
215+
this.page = $event - 1;
216+
this.request = {
217+
...this.request,
218+
page: this.page
219+
};
220+
this.loadKeys();
221+
}
222+
223+
onItemsPerPageChange($event: number) {
224+
this.request = {
225+
...this.request,
226+
size: $event,
227+
page: 0
228+
};
229+
this.page = 0;
230+
this.loadKeys();
231+
}
232+
}

0 commit comments

Comments
 (0)