new calculation approach with unique sessions, new API endpoint to get list of active sessions, fix for UNDEF user, UI and Back to support certificate management still under development
This commit is contained in:
@@ -24,6 +24,9 @@
|
||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||
<input type="text" class="form-control" placeholder="Search by client name..." v-model="searchQuery">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @click="showNewClientModal">
|
||||
<i class="fas fa-plus me-1"></i> New Client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
@@ -33,8 +36,9 @@
|
||||
</div>
|
||||
|
||||
<div class="card" id="certificatesCard">
|
||||
<div class="card-header d-flex justify-content-between align-items-center bg-transparent border-bottom">
|
||||
<div class="card-header d-flex justify-content-between align-items-center bg-transparent">
|
||||
<span><i class="fas fa-certificate me-2"></i>Certificates List</span>
|
||||
<div>
|
||||
<div>
|
||||
<span class="status-badge status-valid me-1">
|
||||
<i class="fas fa-check-circle me-1"></i><span>{{ activeCerts.length }}</span> Active
|
||||
@@ -43,20 +47,23 @@
|
||||
<i class="fas fa-times-circle me-1"></i><span>{{ expiredCerts.length }}</span> Expired
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client Name</th>
|
||||
<th>Type</th> <!-- Preserved Key Info -->
|
||||
<th>Validity Not After</th>
|
||||
<th>Days Remaining</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th> <!-- Preserved Actions -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="4" class="text-center py-4">
|
||||
<td colspan="6" class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
@@ -65,7 +72,7 @@
|
||||
</tr>
|
||||
|
||||
<tr v-else-if="activeCerts.length === 0 && expiredCerts.length === 0">
|
||||
<td colspan="4" class="empty-state text-center py-5">
|
||||
<td colspan="6" class="empty-state text-center py-5">
|
||||
<i class="fas fa-certificate fa-2x mb-3 text-muted"></i>
|
||||
<p class="text-muted">No certificates found</p>
|
||||
</td>
|
||||
@@ -74,37 +81,52 @@
|
||||
<template v-else>
|
||||
<!-- Active Section -->
|
||||
<tr v-if="activeCerts.length > 0" class="section-divider">
|
||||
<td colspan="4">Active Certificates ({{ activeCerts.length }})</td>
|
||||
<td colspan="6">Active Certificates ({{ activeCerts.length }})</td>
|
||||
</tr>
|
||||
<tr v-for="cert in activeCerts" :key="cert.common_name + '_active'">
|
||||
<td>
|
||||
<div class="fw-semibold" style="color: var(--text-heading);">{{ getClientName(cert) }}</div>
|
||||
<div class="certificate-file text-muted small">{{ cert.file || 'N/A' }}</div>
|
||||
</td>
|
||||
<td>{{ formatDate(cert.not_after || cert.expiration_date) }}</td>
|
||||
<td><span :class="getBadgeClass(cert.type)">{{ cert.type }}</span></td>
|
||||
<td>{{ formatDate(cert.not_after || cert.expires_iso) }}</td>
|
||||
<td class="fw-semibold" :class="getDaysClass(cert.days_remaining)">
|
||||
{{ cert.days_remaining || 'N/A' }}
|
||||
</td>
|
||||
<td v-html="getStatusBadgeHTML(cert.days_remaining)"></td>
|
||||
<td>
|
||||
<button v-if="cert.type === 'Client'" class="btn btn-sm btn-link text-primary p-0 me-2" title="Download Config" @click="downloadConfig(cert.common_name || cert.name)">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button v-if="cert.type === 'Client'" class="btn btn-sm btn-link text-danger p-0" title="Revoke" @click="revokeClient(cert.common_name || cert.name)">
|
||||
<i class="fas fa-ban"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expired Section -->
|
||||
<template v-if="!hideExpired && expiredCerts.length > 0">
|
||||
<tr class="section-divider">
|
||||
<td colspan="4">Expired Certificates ({{ expiredCerts.length }})</td>
|
||||
<td colspan="6">Expired Certificates ({{ expiredCerts.length }})</td>
|
||||
</tr>
|
||||
<tr v-for="cert in expiredCerts" :key="cert.common_name + '_expired'">
|
||||
<td>
|
||||
<div class="fw-semibold" style="color: var(--text-heading);">{{ getClientName(cert) }}</div>
|
||||
<div class="certificate-file text-muted small">{{ cert.file || 'N/A' }}</div>
|
||||
</td>
|
||||
<td>{{ formatDate(cert.not_after || cert.expiration_date) }}</td>
|
||||
<td><span :class="getBadgeClass(cert.type)">{{ cert.type }}</span></td>
|
||||
<td>{{ formatDate(cert.not_after || cert.expires_iso) }}</td>
|
||||
<td class="fw-semibold text-danger">
|
||||
{{ formatExpiredDays(cert.days_remaining) }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge status-expired"><i class="fas fa-times-circle me-1"></i>Expired</span>
|
||||
</td>
|
||||
<td>
|
||||
<button v-if="cert.type === 'Client'" class="btn btn-sm btn-link text-danger p-0" title="Revoke" @click="revokeClient(cert.common_name || cert.name)">
|
||||
<i class="fas fa-ban"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
@@ -117,11 +139,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useApi } from '../composables/useApi';
|
||||
import { useFormatters } from '../composables/useFormatters';
|
||||
import Swal from 'sweetalert2';
|
||||
|
||||
const { fetchCertificates } = useApi();
|
||||
// use locally defined format logic to match legacy specificities if needed, but simple date string is likely fine
|
||||
const { formatDate: _formatDate } = useFormatters();
|
||||
const { apiClient } = useApi();
|
||||
|
||||
const loading = ref(true);
|
||||
const allCertificates = ref([]);
|
||||
@@ -129,8 +149,10 @@ const searchQuery = ref('');
|
||||
const hideExpired = ref(false);
|
||||
|
||||
const getClientName = (cert) => {
|
||||
const cn = cert.common_name || cert.subject || 'N/A';
|
||||
return cn.replace('CN=', '').trim();
|
||||
// Backend now provides common_name, or we parse subject. Fallback to name.
|
||||
if (cert.common_name) return cert.common_name;
|
||||
const cn = cert.subject || 'N/A';
|
||||
return cn.replace('CN=', '').trim() || cert.name || 'Unknown';
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
@@ -138,15 +160,13 @@ const formatDate = (dateStr) => {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if(isNaN(d)) return dateStr;
|
||||
// Legacy: 'May 16, 2033 11:32:06 AM' format roughly
|
||||
// Using standard locale string which is close enough and better
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
||||
} catch(e) { return dateStr; }
|
||||
};
|
||||
|
||||
const getDaysClass = (daysText) => {
|
||||
if (!daysText || daysText === 'N/A') return 'text-success'; // default valid
|
||||
if (daysText.includes('Expired')) return 'text-danger';
|
||||
if (daysText.toString().includes('Expired')) return 'text-danger';
|
||||
|
||||
const days = parseInt(daysText);
|
||||
if (!isNaN(days) && days <= 30) return 'text-warning';
|
||||
@@ -154,34 +174,41 @@ const getDaysClass = (daysText) => {
|
||||
};
|
||||
|
||||
const getStatusBadgeHTML = (daysText) => {
|
||||
if (!daysText || daysText === 'N/A') return '<span class="status-badge text-muted">Unknown</span>';
|
||||
if (!daysText || daysText === 'N/A') return '<span class="status-badge status-secondary">Unknown</span>';
|
||||
|
||||
const days = parseInt(daysText);
|
||||
if (!isNaN(days)) {
|
||||
if (days <= 30) {
|
||||
return '<span class="status-badge status-expiring"><i class="fas fa-exclamation-triangle me-1"></i>Expiring Soon</span>';
|
||||
return '<span class="status-badge status-warning"><i class="fas fa-exclamation-triangle me-1"></i>Expiring Soon</span>';
|
||||
} else {
|
||||
return '<span class="status-badge status-valid"><i class="fas fa-check-circle me-1"></i>Valid</span>';
|
||||
}
|
||||
}
|
||||
return '<span class="status-badge text-muted">Unknown</span>';
|
||||
return '<span class="status-badge status-secondary">Unknown</span>';
|
||||
};
|
||||
|
||||
const formatExpiredDays = (daysText) => {
|
||||
if(!daysText) return 'N/A';
|
||||
if (daysText.includes('Expired')) {
|
||||
// Clean up "Expired (X days ago)" logic if present
|
||||
if (daysText.toString().includes('Expired')) {
|
||||
return daysText.replace('Expired (', '').replace(' days ago)', '') + ' days ago';
|
||||
}
|
||||
return daysText;
|
||||
};
|
||||
|
||||
const getBadgeClass = (type) => {
|
||||
if (type === 'CA') return 'status-badge status-warning';
|
||||
if (type === 'Server') return 'status-badge status-server';
|
||||
return 'status-badge status-client';
|
||||
};
|
||||
|
||||
// Data Processing
|
||||
const filteredData = computed(() => {
|
||||
let data = allCertificates.value;
|
||||
if (searchQuery.value) {
|
||||
const term = searchQuery.value.toLowerCase();
|
||||
data = data.filter(c => {
|
||||
const commonName = (c.common_name || c.subject || '').toLowerCase();
|
||||
const commonName = (c.common_name || c.subject || c.name || '').toLowerCase();
|
||||
const fileName = (c.file || '').toLowerCase();
|
||||
return commonName.includes(term) || fileName.includes(term);
|
||||
});
|
||||
@@ -195,8 +222,11 @@ const categorized = computed(() => {
|
||||
|
||||
filteredData.value.forEach(cert => {
|
||||
let isExpired = false;
|
||||
// Backend now returns 'state' too, but we stick to days logic as per snippet
|
||||
if (cert.days_remaining && typeof cert.days_remaining === 'string' && cert.days_remaining.includes('Expired')) {
|
||||
isExpired = true;
|
||||
} else if (cert.state === 'Expired') {
|
||||
isExpired = true;
|
||||
} else if ((!cert.days_remaining || cert.days_remaining === 'N/A') && cert.not_after) {
|
||||
const expDate = new Date(cert.not_after);
|
||||
if (expDate < new Date()) isExpired = true;
|
||||
@@ -234,22 +264,95 @@ const expiringCount = computed(() => {
|
||||
}).length;
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
const loadCerts = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await fetchCertificates();
|
||||
if(res.success) {
|
||||
allCertificates.value = res.data;
|
||||
const response = await apiClient.get('/certificates');
|
||||
if (response.data.success) {
|
||||
let data = response.data.certificates || response.data.data;
|
||||
// Handle Object-based response (index keys)
|
||||
if (data && !Array.isArray(data) && typeof data === 'object') {
|
||||
allCertificates.value = Object.values(data);
|
||||
} else {
|
||||
allCertificates.value = data || [];
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
Swal.fire('Error', 'Failed to load certificates', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showNewClientModal = async () => {
|
||||
const { value: name } = await Swal.fire({
|
||||
title: 'New Client Name',
|
||||
input: 'text',
|
||||
inputLabel: 'Enter client name (CN)',
|
||||
showCancelButton: true,
|
||||
inputValidator: (value) => {
|
||||
if (!value) return 'You need to write something!'
|
||||
}
|
||||
});
|
||||
|
||||
if (name) {
|
||||
try {
|
||||
loading.value = true;
|
||||
const res = await apiClient.post('/pki/client', { name });
|
||||
if (res.data.success) {
|
||||
Swal.fire('Success', 'Client created successfully. You can download the config from the list.', 'success');
|
||||
loadCerts();
|
||||
}
|
||||
} catch(e) {
|
||||
Swal.fire('Error', e.response?.data?.error || e.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const downloadConfig = async (name) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/pki/client/${name}/config`);
|
||||
if(res.data.success) {
|
||||
const blob = new Blob([res.data.config], { type: 'text/plain' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = res.data.filename;
|
||||
link.click();
|
||||
}
|
||||
} catch(e) {
|
||||
Swal.fire('Error', 'Could not download config: ' + (e.response?.data?.error || e.message), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const revokeClient = async (name) => {
|
||||
const result = await Swal.fire({
|
||||
title: 'Revoke Certificate?',
|
||||
text: `Are you sure you want to revoke access for ${name}?`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#d33',
|
||||
confirmButtonText: 'Yes, revoke it!'
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
loading.value = true;
|
||||
await apiClient.delete(`/pki/client/${name}`);
|
||||
Swal.fire('Revoked!', `${name} has been revoked.`, 'success');
|
||||
loadCerts();
|
||||
} catch(e) {
|
||||
Swal.fire('Error', e.response?.data?.error || e.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
loadCerts();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -286,4 +389,8 @@ onMounted(() => {
|
||||
letter-spacing: 0.5px;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user