A Kubernetes operator that bridges Hardware Security Module (HSM) data storage with Kubernetes Secrets, providing true secret portability th
1class HSMTokenManager {
2 constructor(baseUrl = '') {
3 this.baseUrl = baseUrl;
4 this.apiPath = '/api/v1';
5 this.storageKey = 'hsm-token';
6 this.expiryKey = 'hsm-token-expiry';
7 this.cachedToken = null;
8 this.tokenExpiry = null;
9 this.refreshPromise = null;
10 this.loadCachedToken();
11 }
12
13 loadCachedToken() {
14 const token = localStorage.getItem(this.storageKey);
15 const expiry = localStorage.getItem(this.expiryKey);
16
17 if (token && expiry) {
18 this.cachedToken = token;
19 this.tokenExpiry = new Date(expiry);
20 }
21 }
22
23 isTokenValid() {
24 if (!this.cachedToken || !this.tokenExpiry) {
25 return false;
26 }
27
28 // Consider token invalid if it expires within 5 minutes
29 const bufferTime = 5 * 60 * 1000; // 5 minutes in milliseconds
30 return new Date() < (new Date(this.tokenExpiry.getTime() - bufferTime));
31 }
32
33 async getValidToken() {
34 // Return cached token if still valid
35 if (this.isTokenValid()) {
36 return this.cachedToken;
37 }
38
39 // If already refreshing, wait for that promise
40 if (this.refreshPromise) {
41 return await this.refreshPromise;
42 }
43
44 // Start token refresh
45 this.refreshPromise = this.refreshToken();
46
47 try {
48 const token = await this.refreshPromise;
49 return token;
50 } finally {
51 this.refreshPromise = null;
52 }
53 }
54
55 async refreshToken() {
56 try {
57 // First try to get a K8s token automatically (if kubectl is configured)
58 let k8sToken = await this.getK8sToken();
59
60 if (!k8sToken) {
61 // Prompt user for token
62 k8sToken = await this.promptForK8sToken();
63 }
64
65 // Exchange K8s token for HSM JWT
66 const response = await fetch(`${this.baseUrl}${this.apiPath}/auth/token`, {
67 method: 'POST',
68 headers: {
69 'Content-Type': 'application/json'
70 },
71 body: JSON.stringify({ k8s_token: k8sToken })
72 });
73
74 if (!response.ok) {
75 const errorData = await response.json();
76 throw new Error(errorData.error || `HTTP ${response.status}`);
77 }
78
79 const data = await response.json();
80
81 // Cache the new token
82 this.cachedToken = data.token;
83 this.tokenExpiry = new Date(data.expires_at);
84
85 localStorage.setItem(this.storageKey, this.cachedToken);
86 localStorage.setItem(this.expiryKey, this.tokenExpiry.toISOString());
87
88 return this.cachedToken;
89 } catch (error) {
90 console.error('Token refresh failed:', error);
91 this.clearToken();
92 throw error;
93 }
94 }
95
96 async getK8sToken() {
97 // This would work if the web UI had access to kubectl context
98 // For now, we'll return null to trigger user prompt
99 return null;
100 }
101
102 async promptForK8sToken() {
103 return new Promise((resolve, reject) => {
104 // Create modal dialog
105 const modal = this.createTokenModal();
106 document.body.appendChild(modal);
107
108 // Focus on input
109 const input = modal.querySelector('#tokenInput');
110 const submitBtn = modal.querySelector('#submitToken');
111 const cancelBtn = modal.querySelector('#cancelToken');
112
113 input.focus();
114
115 const cleanup = () => {
116 document.body.removeChild(modal);
117 };
118
119 submitBtn.onclick = () => {
120 const token = input.value.trim();
121 if (token) {
122 cleanup();
123 resolve(token);
124 } else {
125 alert('Please enter a valid token');
126 }
127 };
128
129 cancelBtn.onclick = () => {
130 cleanup();
131 reject(new Error('Authentication cancelled by user'));
132 };
133
134 // Submit on Enter
135 input.onkeydown = (e) => {
136 if (e.key === 'Enter') {
137 submitBtn.click();
138 }
139 };
140 });
141 }
142
143 createTokenModal() {
144 const modal = document.createElement('div');
145 modal.className = 'auth-modal';
146 modal.innerHTML = `
147 <div class="auth-modal-content">
148 <h2>🔐 Authentication Required</h2>
149 <p>The HSM Secrets API requires authentication. Please provide a Kubernetes service account token.</p>
150
151 <div class="auth-instructions">
152 <p><strong>To get a token, run this command:</strong></p>
153 <code>kubectl create token hsm-web-ui-sa --duration=8h</code>
154 <p><small>Replace <code>hsm-web-ui-sa</code> with your service account name</small></p>
155 </div>
156
157 <div class="form-group">
158 <label for="tokenInput">Service Account Token:</label>
159 <textarea id="tokenInput" placeholder="Paste your Kubernetes service account token here..." rows="4"></textarea>
160 </div>
161
162 <div class="auth-actions">
163 <button id="submitToken" class="btn">Login</button>
164 <button id="cancelToken" class="btn btn-secondary">Cancel</button>
165 </div>
166 </div>
167 `;
168 return modal;
169 }
170
171 clearToken() {
172 this.cachedToken = null;
173 this.tokenExpiry = null;
174 localStorage.removeItem(this.storageKey);
175 localStorage.removeItem(this.expiryKey);
176 }
177
178 getTokenInfo() {
179 if (!this.cachedToken || !this.tokenExpiry) {
180 return { authenticated: false };
181 }
182
183 return {
184 authenticated: true,
185 expiresAt: this.tokenExpiry,
186 valid: this.isTokenValid()
187 };
188 }
189}
190
191class HSMSecretsAPI {
192 constructor(baseUrl = '') {
193 this.baseUrl = baseUrl;
194 this.apiPath = '/api/v1';
195 this.tokenManager = new HSMTokenManager(baseUrl);
196 }
197
198 async request(path, options = {}) {
199 const url = `${this.baseUrl}${this.apiPath}${path}`;
200
201 // Skip authentication for health and auth endpoints
202 const skipAuth = path.includes('/health') || path.includes('/auth/token');
203
204 const headers = {
205 'Content-Type': 'application/json',
206 ...options.headers
207 };
208
209 // Add authentication header if not skipping auth
210 if (!skipAuth) {
211 try {
212 const token = await this.tokenManager.getValidToken();
213 headers['Authorization'] = `Bearer ${token}`;
214 } catch (error) {
215 throw new Error(`Authentication failed: ${error.message}`);
216 }
217 }
218
219 const config = {
220 headers,
221 ...options
222 };
223
224 try {
225 const response = await fetch(url, config);
226 const data = await response.json();
227
228 if (!response.ok) {
229 // Handle authentication errors specifically
230 if (response.status === 401) {
231 this.tokenManager.clearToken();
232 throw new Error('Authentication failed. Please login again.');
233 }
234 throw new Error(data.error?.message || `HTTP ${response.status}`);
235 }
236
237 return data;
238 } catch (error) {
239 console.error('API Request failed:', error);
240 throw error;
241 }
242 }
243
244 async getHealth() {
245 return this.request('/health');
246 }
247
248 async listSecrets(page = 1, pageSize = 100) {
249 return this.request(`/hsm/secrets?page=${page}&page_size=${pageSize}`);
250 }
251
252 async getSecret(secretName) {
253 return this.request(`/hsm/secrets/${encodeURIComponent(secretName)}`);
254 }
255
256 async getDeviceStatus() {
257 return this.request('/hsm/status');
258 }
259
260 async getDeviceInfo() {
261 return this.request('/hsm/info');
262 }
263
264 async createSecret(secretName, data, metadata = null) {
265 const requestBody = { data };
266 if (metadata) {
267 requestBody.metadata = metadata;
268 }
269 return this.request(`/hsm/secrets/${encodeURIComponent(secretName)}`, {
270 method: 'POST',
271 body: JSON.stringify(requestBody)
272 });
273 }
274
275 async deleteSecret(secretName) {
276 return this.request(`/hsm/secrets/${encodeURIComponent(secretName)}`, {
277 method: 'DELETE'
278 });
279 }
280}
281
282class HSMSecretsUI {
283 constructor() {
284 this.api = new HSMSecretsAPI();
285 this.secrets = [];
286 this.init();
287 }
288
289 init() {
290 this.kvPairCounter = 0;
291 this.labelPairCounter = 0;
292 this.setupEventListeners();
293 this.loadInitialData();
294 this.initializeCreateForm();
295 this.updateAuthStatus();
296
297 // Update auth status every 30 seconds
298 setInterval(() => this.updateAuthStatus(), 30000);
299 }
300
301 initializeCreateForm() {
302 // Add initial empty key-value pair to the form
303 this.addKeyValuePair();
304 // Add initial empty label pair to the metadata form
305 this.addLabelPair();
306 }
307
308 setupEventListeners() {
309 const createForm = document.getElementById('createForm');
310 createForm.addEventListener('submit', (e) => this.handleCreateSecret(e));
311 }
312
313 async loadInitialData() {
314 await this.checkAPIHealth();
315 await this.loadDeviceStatus();
316 await this.loadSecrets();
317 }
318
319 async checkAPIHealth() {
320 try {
321 const health = await this.api.getHealth();
322 const statusElement = document.getElementById('apiStatus');
323 const deviceCountElement = document.getElementById('deviceCount');
324
325 if (health.success && health.data.status === 'healthy') {
326 statusElement.textContent = '✅ Healthy';
327 statusElement.style.color = '#22543d';
328 } else {
329 statusElement.textContent = '⚠️ Degraded';
330 statusElement.style.color = '#dd6b20';
331 }
332
333 // Update device count if available
334 if (deviceCountElement && health.data.activeNodes !== undefined) {
335 deviceCountElement.textContent = health.data.activeNodes;
336 }
337 } catch (error) {
338 const statusElement = document.getElementById('apiStatus');
339 statusElement.textContent = '❌ Error';
340 statusElement.style.color = '#c53030';
341 console.error('Health check failed:', error);
342 }
343 }
344
345 async loadDeviceStatus() {
346 const statusElement = document.getElementById('deviceStatus');
347 statusElement.innerHTML = '<div class="loading">Loading device status...</div>';
348
349 try {
350 const [statusResponse, infoResponse] = await Promise.all([
351 this.api.getDeviceStatus(),
352 this.api.getDeviceInfo()
353 ]);
354
355 const devices = statusResponse.data.devices || {};
356 const deviceInfos = infoResponse.data.deviceInfos || {};
357 const totalDevices = statusResponse.data.totalDevices || 0;
358
359 this.renderDeviceStatus(devices, deviceInfos, totalDevices);
360 } catch (error) {
361 this.showError(statusElement, `Failed to load device status: ${error.message}`);
362 }
363 }
364
365 renderDeviceStatus(devices, deviceInfos, totalDevices) {
366 const statusElement = document.getElementById('deviceStatus');
367
368 if (totalDevices === 0) {
369 statusElement.innerHTML = '<p style="text-align: center; color: #666; padding: 20px;">No HSM devices found.</p>';
370 return;
371 }
372
373 const deviceItems = Object.entries(devices).map(([deviceName, isConnected]) => {
374 const info = deviceInfos[deviceName];
375 const statusIcon = isConnected ? '🟢' : '🔴';
376 const statusText = isConnected ? 'Connected' : 'Disconnected';
377
378 return `
379 <div class="device-item ${isConnected ? 'connected' : 'disconnected'}">
380 <div class="device-header">
381 <span class="device-name">${statusIcon} ${this.escapeHtml(deviceName)}</span>
382 <span class="device-status-badge">${statusText}</span>
383 </div>
384 ${info ? `
385 <div class="device-details">
386 <div class="device-info">
387 <span>Manufacturer: ${this.escapeHtml(info.manufacturer || 'Unknown')}</span>
388 <span>Model: ${this.escapeHtml(info.model || 'Unknown')}</span>
389 <span>Serial: ${this.escapeHtml(info.serialNumber || 'Unknown')}</span>
390 </div>
391 </div>
392 ` : ''}
393 </div>
394 `;
395 }).join('');
396
397 statusElement.innerHTML = deviceItems;
398 }
399
400 async loadSecrets() {
401 const listElement = document.getElementById('secretsList');
402 listElement.innerHTML = '<div class="loading">Loading secrets...</div>';
403
404 try {
405 const response = await this.api.listSecrets();
406 this.secrets = response.data.secrets || [];
407
408 document.getElementById('totalSecrets').textContent = this.secrets.length;
409
410 this.renderSecretsList();
411 } catch (error) {
412 this.showError(listElement, `Failed to load secrets: ${error.message}`);
413 }
414 }
415
416 renderSecretsList() {
417 const listElement = document.getElementById('secretsList');
418
419 if (this.secrets.length === 0) {
420 listElement.innerHTML = '<p style="text-align: center; color: #666; padding: 20px;">No secrets found. Create your first secret!</p>';
421 return;
422 }
423
424 listElement.innerHTML = this.secrets.map(secretName => `
425 <div class="secret-item">
426 <div class="secret-name">🔐 ${this.escapeHtml(secretName)}</div>
427 <div class="secret-actions">
428 <button class="btn btn-secondary" onclick="ui.viewSecret('${this.escapeHtml(secretName)}')">
429 👁️ View
430 </button>
431 <button class="btn btn-danger" onclick="ui.deleteSecret('${this.escapeHtml(secretName)}')">
432 🗑️ Delete
433 </button>
434 </div>
435 </div>
436 `).join('');
437 }
438
439 async viewSecret(secretName) {
440 const viewSection = document.getElementById('viewSection');
441 const viewMessage = document.getElementById('viewMessage');
442 const detailsElement = document.getElementById('secretDetails');
443
444 viewSection.style.display = 'block';
445 viewMessage.innerHTML = '';
446 detailsElement.innerHTML = '<div class="loading">Loading secret details...</div>';
447
448 // Scroll to view section
449 viewSection.scrollIntoView({ behavior: 'smooth' });
450
451 try {
452 const response = await this.api.getSecret(secretName);
453 const secretData = response.data;
454
455 // Convert byte arrays to strings for display
456 const displayData = {};
457 if (secretData.data) {
458 for (const [key, value] of Object.entries(secretData.data)) {
459 // Handle byte arrays by converting to string
460 if (Array.isArray(value)) {
461 displayData[key] = String.fromCharCode.apply(null, value);
462 } else {
463 displayData[key] = value;
464 }
465 }
466 }
467
468 const deviceBadge = secretData.deviceCount > 1 ?
469 `<span class="device-badge multi-device">${secretData.deviceCount} devices</span>` :
470 `<span class="device-badge single-device">1 device</span>`;
471
472 detailsElement.innerHTML = `
473 <h3>Secret: ${this.escapeHtml(secretName)} ${deviceBadge}</h3>
474 <div class="secret-metadata">
475 <div class="metadata-item">
476 <strong>Path:</strong> ${this.escapeHtml(secretData.path || secretName)}
477 </div>
478 <div class="metadata-item">
479 <strong>Checksum:</strong> ${this.escapeHtml(secretData.checksum || 'N/A')}
480 </div>
481 <div class="metadata-item">
482 <strong>Keys:</strong> ${Object.keys(displayData).length}
483 </div>
484 ${secretData.deviceCount ? `
485 <div class="metadata-item">
486 <strong>Device Count:</strong> ${secretData.deviceCount}
487 </div>
488 ` : ''}
489 </div>
490 <div class="secret-data">
491 <strong>Data:</strong>
492 <div class="json-preview">${this.escapeHtml(JSON.stringify(displayData, null, 2))}</div>
493 </div>
494 `;
495 } catch (error) {
496 this.showError(viewMessage, `Failed to load secret: ${error.message}`);
497 detailsElement.innerHTML = '';
498 }
499 }
500
501 async deleteSecret(secretName) {
502 if (!confirm(`Are you sure you want to delete the secret "${secretName}"? This action cannot be undone.`)) {
503 return;
504 }
505
506 try {
507 await this.api.deleteSecret(secretName);
508 this.showSuccess(null, `Secret "${secretName}" deleted successfully!`);
509 await this.loadSecrets(); // Refresh after deletion
510 } catch (error) {
511 this.showError(null, `Failed to delete secret: ${error.message}`);
512 }
513 }
514
515 showCreateForm() {
516 document.getElementById('createSection').style.display = 'block';
517 document.getElementById('secretName').focus();
518 document.getElementById('createSection').scrollIntoView({ behavior: 'smooth' });
519 }
520
521 hideCreateForm() {
522 document.getElementById('createSection').style.display = 'none';
523 document.getElementById('createForm').reset();
524 document.getElementById('createMessage').innerHTML = '';
525
526 // Reset key-value pairs to single empty pair
527 const kvPairs = document.getElementById('kvPairs');
528 kvPairs.innerHTML = '';
529 this.kvPairCounter = 0;
530 this.addKeyValuePair(); // Add one empty pair
531
532 // Reset label pairs and advanced section
533 const labelPairs = document.getElementById('labelPairs');
534 labelPairs.innerHTML = '';
535 this.labelPairCounter = 0;
536 this.addLabelPair(); // Add one empty label pair
537
538 // Close advanced section
539 const advancedContent = document.getElementById('advancedContent');
540 const advancedToggle = document.querySelector('.advanced-toggle');
541 advancedContent.classList.remove('show');
542 advancedToggle.classList.remove('expanded');
543 }
544
545 hideViewSection() {
546 document.getElementById('viewSection').style.display = 'none';
547 document.getElementById('viewMessage').innerHTML = '';
548 }
549
550 addKeyValuePair(key = '', value = '') {
551 const kvPairs = document.getElementById('kvPairs');
552
553 const pairId = this.kvPairCounter++;
554 const pairDiv = document.createElement('div');
555 pairDiv.className = 'kv-pair';
556 pairDiv.id = `kvPair${pairId}`;
557
558 pairDiv.innerHTML = `
559 <input type="text" name="key${pairId}" placeholder="Key (e.g., api_key)" value="${this.escapeHtml(key)}" required>
560 <input type="text" name="value${pairId}" placeholder="Value" value="${this.escapeHtml(value)}" required>
561 <button type="button" class="btn btn-remove btn-small" onclick="ui.removeKeyValuePair('kvPair${pairId}')" title="Remove this key-value pair">
562 ➖
563 </button>
564 `;
565
566 kvPairs.appendChild(pairDiv);
567
568 // Focus on the key input for new pairs (but not during initial load)
569 if (!key && kvPairs.children.length > 1) {
570 pairDiv.querySelector('input[name^="key"]').focus();
571 }
572 }
573
574 removeKeyValuePair(pairId) {
575 const kvPairs = document.getElementById('kvPairs');
576 const pairElement = document.getElementById(pairId);
577
578 // Don't allow removing the last pair
579 if (kvPairs.children.length <= 1) {
580 return;
581 }
582
583 if (pairElement) {
584 pairElement.remove();
585 }
586 }
587
588 collectKeyValuePairs() {
589 const kvPairs = document.getElementById('kvPairs');
590 const pairs = kvPairs.querySelectorAll('.kv-pair');
591 const data = {};
592
593 for (const pair of pairs) {
594 const keyInput = pair.querySelector('input[name^="key"]');
595 const valueInput = pair.querySelector('input[name^="value"]');
596
597 if (keyInput && valueInput) {
598 const key = keyInput.value.trim();
599 const value = valueInput.value.trim();
600
601 if (key && value) {
602 data[key] = value;
603 }
604 }
605 }
606
607 return data;
608 }
609
610 toggleAdvanced() {
611 const content = document.getElementById('advancedContent');
612 const toggle = document.querySelector('.advanced-toggle');
613
614 content.classList.toggle('show');
615 toggle.classList.toggle('expanded');
616 }
617
618 addLabelPair(key = '', value = '') {
619 const labelPairs = document.getElementById('labelPairs');
620
621 const pairId = this.labelPairCounter++;
622 const pairDiv = document.createElement('div');
623 pairDiv.className = 'tag-pair';
624 pairDiv.id = `labelPair${pairId}`;
625
626 pairDiv.innerHTML = `
627 <input type="text" name="labelKey${pairId}" placeholder="Label key (e.g., app, environment)" value="${this.escapeHtml(key)}">
628 <input type="text" name="labelValue${pairId}" placeholder="Label value (e.g., backend, production)" value="${this.escapeHtml(value)}">
629 <button type="button" class="btn btn-remove btn-small" onclick="ui.removeLabelPair('labelPair${pairId}')" title="Remove this label">
630 ➖
631 </button>
632 `;
633
634 labelPairs.appendChild(pairDiv);
635
636 // Focus on the key input for new pairs (but not during initial load)
637 if (!key && labelPairs.children.length > 1) {
638 pairDiv.querySelector('input[name^="labelKey"]').focus();
639 }
640 }
641
642 removeLabelPair(pairId) {
643 const labelPairs = document.getElementById('labelPairs');
644 const pairElement = document.getElementById(pairId);
645
646 // Don't allow removing the last pair
647 if (labelPairs.children.length <= 1) {
648 return;
649 }
650
651 if (pairElement) {
652 pairElement.remove();
653 }
654 }
655
656 collectLabelPairs() {
657 const labelPairs = document.getElementById('labelPairs');
658 const pairs = labelPairs.querySelectorAll('.tag-pair');
659 const labels = {};
660
661 for (const pair of pairs) {
662 const keyInput = pair.querySelector('input[name^="labelKey"]');
663 const valueInput = pair.querySelector('input[name^="labelValue"]');
664
665 if (keyInput && valueInput) {
666 const key = keyInput.value.trim();
667 const value = valueInput.value.trim();
668
669 if (key && value) {
670 labels[key] = value;
671 }
672 }
673 }
674
675 return labels;
676 }
677
678 collectMetadata() {
679 const description = document.getElementById('metadataDescription').value.trim();
680 const format = document.getElementById('metadataFormat').value.trim();
681 const dataType = document.getElementById('metadataDataType').value.trim();
682 const source = document.getElementById('metadataSource').value.trim();
683 const labels = this.collectLabelPairs();
684
685 // Only return metadata if at least one field is filled
686 if (!description && !format && !dataType && !source && Object.keys(labels).length === 0) {
687 return null;
688 }
689
690 const metadata = {};
691 if (description) metadata.description = description;
692 if (format) metadata.format = format;
693 if (dataType) metadata.data_type = dataType;
694 if (source) metadata.source = source;
695 if (Object.keys(labels).length > 0) metadata.labels = labels;
696
697 // Add creation timestamp
698 metadata.created_at = new Date().toISOString();
699
700 return metadata;
701 }
702
703 async handleCreateSecret(event) {
704 event.preventDefault();
705
706 const messageElement = document.getElementById('createMessage');
707 const formData = new FormData(event.target);
708 const secretName = formData.get('secretName').trim();
709
710 messageElement.innerHTML = '';
711
712 // Validate inputs
713 if (!secretName) {
714 this.showError(messageElement, 'Secret name is required');
715 return;
716 }
717
718 // Collect key-value pairs
719 const secretData = this.collectKeyValuePairs();
720
721 if (Object.keys(secretData).length === 0) {
722 this.showError(messageElement, 'At least one key-value pair is required');
723 return;
724 }
725
726 // Collect metadata if any is provided
727 const metadata = this.collectMetadata();
728
729 // Validate key names (no spaces, no special chars except underscore)
730 for (const key of Object.keys(secretData)) {
731 if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) {
732 this.showError(messageElement, `Invalid key "${key}". Keys must start with a letter and contain only letters, numbers, and underscores.`);
733 return;
734 }
735 }
736
737 // Get submit button and store original text
738 const submitBtn = event.target.querySelector('button[type="submit"]');
739 const originalText = submitBtn.textContent;
740
741 try {
742 // Show loading state
743 submitBtn.textContent = 'Creating...';
744 submitBtn.disabled = true;
745
746 await this.api.createSecret(secretName, secretData, metadata);
747
748 this.showSuccess(messageElement, `Secret "${secretName}" created successfully!`);
749
750 // Reset form and refresh list
751 event.target.reset();
752 await this.loadSecrets(); // Refresh after creation
753
754 // Hide form after a delay
755 setTimeout(() => this.hideCreateForm(), 2000);
756
757 } catch (error) {
758 this.showError(messageElement, `Failed to create secret: ${error.message}`);
759 } finally {
760 // Restore button state
761 submitBtn.textContent = originalText;
762 submitBtn.disabled = false;
763 }
764 }
765
766 async refreshSecrets() {
767 await this.loadSecrets();
768 }
769
770 async refreshDeviceStatus() {
771 await this.loadDeviceStatus();
772 }
773
774 async refreshAll() {
775 await this.loadInitialData();
776 this.updateAuthStatus();
777 }
778
779 updateAuthStatus() {
780 const authInfo = this.api.tokenManager.getTokenInfo();
781 const authElement = document.getElementById('authStatus');
782
783 if (authElement) {
784 if (authInfo.authenticated && authInfo.valid) {
785 const expiresIn = Math.floor((authInfo.expiresAt - new Date()) / (1000 * 60)); // minutes
786 authElement.innerHTML = `✅ Authenticated (expires in ${expiresIn}m)`;
787 authElement.className = 'auth-status authenticated';
788 } else if (authInfo.authenticated && !authInfo.valid) {
789 authElement.innerHTML = `⚠️ Token Expired`;
790 authElement.className = 'auth-status expired';
791 } else {
792 authElement.innerHTML = `❌ Not Authenticated`;
793 authElement.className = 'auth-status not-authenticated';
794 }
795 }
796 }
797
798 logout() {
799 if (confirm('Are you sure you want to logout? You will need to provide a new token to continue using the HSM API.')) {
800 this.api.tokenManager.clearToken();
801 this.updateAuthStatus();
802 this.showSuccess(null, 'Logged out successfully. You will be prompted for authentication on your next API request.');
803 }
804 }
805
806 showError(element, message) {
807 const errorHTML = `<div class="error">❌ ${this.escapeHtml(message)}</div>`;
808 if (element) {
809 element.innerHTML = errorHTML;
810 } else {
811 // Show at top of page
812 const container = document.querySelector('.container');
813 const existingError = container.querySelector('.error');
814 if (existingError) {
815 existingError.remove();
816 }
817 container.insertAdjacentHTML('afterbegin', errorHTML);
818
819 // Remove after 5 seconds
820 setTimeout(() => {
821 const errorEl = container.querySelector('.error');
822 if (errorEl) errorEl.remove();
823 }, 5000);
824 }
825 }
826
827 showSuccess(element, message) {
828 const successHTML = `<div class="success">✅ ${this.escapeHtml(message)}</div>`;
829 if (element) {
830 element.innerHTML = successHTML;
831 } else {
832 // Show at top of page
833 const container = document.querySelector('.container');
834 const existingSuccess = container.querySelector('.success');
835 if (existingSuccess) {
836 existingSuccess.remove();
837 }
838 container.insertAdjacentHTML('afterbegin', successHTML);
839
840 // Remove after 5 seconds
841 setTimeout(() => {
842 const successEl = container.querySelector('.success');
843 if (successEl) successEl.remove();
844 }, 5000);
845 }
846 }
847
848 escapeHtml(unsafe) {
849 return unsafe
850 .replace(/&/g, "&")
851 .replace(/</g, "<")
852 .replace(/>/g, ">")
853 .replace(/"/g, """)
854 .replace(/'/g, "'");
855 }
856}
857
858// Global functions for onclick handlers
859let ui;
860
861window.addEventListener('DOMContentLoaded', () => {
862 ui = new HSMSecretsUI();
863 // Expose ui object globally for onclick handlers
864 window.ui = ui;
865});
866
867// Expose functions globally for onclick handlers
868window.refreshSecrets = () => ui.refreshSecrets();
869window.refreshDeviceStatus = () => ui.refreshDeviceStatus();
870window.refreshAll = () => ui.refreshAll();
871window.showCreateForm = () => ui.showCreateForm();
872window.hideCreateForm = () => ui.hideCreateForm();
873window.hideViewSection = () => ui.hideViewSection();
874window.logout = () => ui.logout();
875