2026-01-09 10:30:49 +03:00
|
|
|
<template>
|
|
|
|
|
<div class="stats-info mb-4" id="statsInfo">
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
<div class="stat-value">{{ totalCerts }}</div>
|
|
|
|
|
<div class="stat-label">Total Certificates</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
<div class="stat-value">{{ activeCerts.length }}</div>
|
|
|
|
|
<div class="stat-label">Active Certificates</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
<div class="stat-value">{{ expiringCount }}</div>
|
|
|
|
|
<div class="stat-label">Expiring in 30 days</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
<div class="stat-value">{{ expiredCerts.length }}</div>
|
|
|
|
|
<div class="stat-label">Expired Certificates</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-3">
|
|
|
|
|
<div class="d-flex gap-3 align-items-center flex-wrap">
|
|
|
|
|
<div class="input-group input-group-sm" style="width: 250px;">
|
|
|
|
|
<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>
|
2026-01-12 11:43:22 +03:00
|
|
|
<button class="btn btn-sm btn-primary" @click="showNewClientModal">
|
|
|
|
|
<i class="fas fa-plus me-1"></i> New Client
|
|
|
|
|
</button>
|
2026-01-09 10:30:49 +03:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="form-check form-switch">
|
|
|
|
|
<input class="form-check-input" type="checkbox" id="hideExpired" v-model="hideExpired">
|
|
|
|
|
<label class="form-check-label user-select-none text-muted" for="hideExpired">Hide Expired Certificates</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="card" id="certificatesCard">
|
2026-01-12 11:43:22 +03:00
|
|
|
<div class="card-header d-flex justify-content-between align-items-center bg-transparent">
|
2026-01-09 10:30:49 +03:00
|
|
|
<span><i class="fas fa-certificate me-2"></i>Certificates List</span>
|
2026-01-12 11:43:22 +03:00
|
|
|
<div>
|
2026-01-09 10:30:49 +03:00
|
|
|
<div>
|
|
|
|
|
<span class="status-badge status-valid me-1">
|
|
|
|
|
<i class="fas fa-check-circle me-1"></i><span>{{ activeCerts.length }}</span> Active
|
|
|
|
|
</span>
|
|
|
|
|
<span class="status-badge status-expired">
|
|
|
|
|
<i class="fas fa-times-circle me-1"></i><span>{{ expiredCerts.length }}</span> Expired
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-01-12 11:43:22 +03:00
|
|
|
</div>
|
2026-01-09 10:30:49 +03:00
|
|
|
</div>
|
|
|
|
|
<div class="table-responsive">
|
|
|
|
|
<table class="table table-hover mb-0">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Client Name</th>
|
2026-01-12 11:43:22 +03:00
|
|
|
<th>Type</th> <!-- Preserved Key Info -->
|
2026-01-09 10:30:49 +03:00
|
|
|
<th>Validity Not After</th>
|
|
|
|
|
<th>Days Remaining</th>
|
|
|
|
|
<th>Status</th>
|
2026-01-12 11:43:22 +03:00
|
|
|
<th>Actions</th> <!-- Preserved Actions -->
|
2026-01-09 10:30:49 +03:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr v-if="loading">
|
2026-01-12 11:43:22 +03:00
|
|
|
<td colspan="6" class="text-center py-4">
|
2026-01-09 10:30:49 +03:00
|
|
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
|
|
|
|
<span class="visually-hidden">Loading...</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="mt-2 mb-0">Loading certificates...</p>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
<tr v-else-if="activeCerts.length === 0 && expiredCerts.length === 0">
|
2026-01-12 11:43:22 +03:00
|
|
|
<td colspan="6" class="empty-state text-center py-5">
|
2026-01-09 10:30:49 +03:00
|
|
|
<i class="fas fa-certificate fa-2x mb-3 text-muted"></i>
|
|
|
|
|
<p class="text-muted">No certificates found</p>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
<!-- Active Section -->
|
|
|
|
|
<tr v-if="activeCerts.length > 0" class="section-divider">
|
2026-01-12 11:43:22 +03:00
|
|
|
<td colspan="6">Active Certificates ({{ activeCerts.length }})</td>
|
2026-01-09 10:30:49 +03:00
|
|
|
</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>
|
2026-01-12 11:43:22 +03:00
|
|
|
<td><span :class="getBadgeClass(cert.type)">{{ cert.type }}</span></td>
|
|
|
|
|
<td>{{ formatDate(cert.not_after || cert.expires_iso) }}</td>
|
2026-01-09 10:30:49 +03:00
|
|
|
<td class="fw-semibold" :class="getDaysClass(cert.days_remaining)">
|
|
|
|
|
{{ cert.days_remaining || 'N/A' }}
|
|
|
|
|
</td>
|
|
|
|
|
<td v-html="getStatusBadgeHTML(cert.days_remaining)"></td>
|
2026-01-12 11:43:22 +03:00
|
|
|
<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>
|
2026-01-09 10:30:49 +03:00
|
|
|
</tr>
|
|
|
|
|
|
|
|
|
|
<!-- Expired Section -->
|
|
|
|
|
<template v-if="!hideExpired && expiredCerts.length > 0">
|
|
|
|
|
<tr class="section-divider">
|
2026-01-12 11:43:22 +03:00
|
|
|
<td colspan="6">Expired Certificates ({{ expiredCerts.length }})</td>
|
2026-01-09 10:30:49 +03:00
|
|
|
</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>
|
2026-01-12 11:43:22 +03:00
|
|
|
<td><span :class="getBadgeClass(cert.type)">{{ cert.type }}</span></td>
|
|
|
|
|
<td>{{ formatDate(cert.not_after || cert.expires_iso) }}</td>
|
2026-01-09 10:30:49 +03:00
|
|
|
<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>
|
2026-01-12 11:43:22 +03:00
|
|
|
<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>
|
2026-01-09 10:30:49 +03:00
|
|
|
</tr>
|
|
|
|
|
</template>
|
|
|
|
|
</template>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref, computed, onMounted } from 'vue';
|
|
|
|
|
import { useApi } from '../composables/useApi';
|
2026-01-12 11:43:22 +03:00
|
|
|
import Swal from 'sweetalert2';
|
2026-01-09 10:30:49 +03:00
|
|
|
|
2026-01-12 11:43:22 +03:00
|
|
|
const { apiClient } = useApi();
|
2026-01-09 10:30:49 +03:00
|
|
|
|
|
|
|
|
const loading = ref(true);
|
|
|
|
|
const allCertificates = ref([]);
|
|
|
|
|
const searchQuery = ref('');
|
|
|
|
|
const hideExpired = ref(false);
|
|
|
|
|
|
|
|
|
|
const getClientName = (cert) => {
|
2026-01-12 11:43:22 +03:00
|
|
|
// 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';
|
2026-01-09 10:30:49 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateStr) => {
|
|
|
|
|
if (!dateStr || dateStr === 'N/A') return 'N/A';
|
|
|
|
|
try {
|
|
|
|
|
const d = new Date(dateStr);
|
|
|
|
|
if(isNaN(d)) return dateStr;
|
|
|
|
|
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
|
|
|
|
} catch(e) { return dateStr; }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getDaysClass = (daysText) => {
|
|
|
|
|
if (!daysText || daysText === 'N/A') return 'text-success'; // default valid
|
2026-01-12 11:43:22 +03:00
|
|
|
if (daysText.toString().includes('Expired')) return 'text-danger';
|
2026-01-09 10:30:49 +03:00
|
|
|
|
|
|
|
|
const days = parseInt(daysText);
|
|
|
|
|
if (!isNaN(days) && days <= 30) return 'text-warning';
|
|
|
|
|
return 'text-success';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getStatusBadgeHTML = (daysText) => {
|
2026-01-12 11:43:22 +03:00
|
|
|
if (!daysText || daysText === 'N/A') return '<span class="status-badge status-secondary">Unknown</span>';
|
2026-01-09 10:30:49 +03:00
|
|
|
|
|
|
|
|
const days = parseInt(daysText);
|
|
|
|
|
if (!isNaN(days)) {
|
|
|
|
|
if (days <= 30) {
|
2026-01-12 11:43:22 +03:00
|
|
|
return '<span class="status-badge status-warning"><i class="fas fa-exclamation-triangle me-1"></i>Expiring Soon</span>';
|
2026-01-09 10:30:49 +03:00
|
|
|
} else {
|
|
|
|
|
return '<span class="status-badge status-valid"><i class="fas fa-check-circle me-1"></i>Valid</span>';
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-12 11:43:22 +03:00
|
|
|
return '<span class="status-badge status-secondary">Unknown</span>';
|
2026-01-09 10:30:49 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatExpiredDays = (daysText) => {
|
|
|
|
|
if(!daysText) return 'N/A';
|
2026-01-12 11:43:22 +03:00
|
|
|
// Clean up "Expired (X days ago)" logic if present
|
|
|
|
|
if (daysText.toString().includes('Expired')) {
|
2026-01-09 10:30:49 +03:00
|
|
|
return daysText.replace('Expired (', '').replace(' days ago)', '') + ' days ago';
|
|
|
|
|
}
|
|
|
|
|
return daysText;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-12 11:43:22 +03:00
|
|
|
const getBadgeClass = (type) => {
|
|
|
|
|
if (type === 'CA') return 'status-badge status-warning';
|
|
|
|
|
if (type === 'Server') return 'status-badge status-server';
|
|
|
|
|
return 'status-badge status-client';
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-09 10:30:49 +03:00
|
|
|
// Data Processing
|
|
|
|
|
const filteredData = computed(() => {
|
|
|
|
|
let data = allCertificates.value;
|
|
|
|
|
if (searchQuery.value) {
|
|
|
|
|
const term = searchQuery.value.toLowerCase();
|
|
|
|
|
data = data.filter(c => {
|
2026-01-12 11:43:22 +03:00
|
|
|
const commonName = (c.common_name || c.subject || c.name || '').toLowerCase();
|
2026-01-09 10:30:49 +03:00
|
|
|
const fileName = (c.file || '').toLowerCase();
|
|
|
|
|
return commonName.includes(term) || fileName.includes(term);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return data;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const categorized = computed(() => {
|
|
|
|
|
const active = [];
|
|
|
|
|
const expired = [];
|
|
|
|
|
|
|
|
|
|
filteredData.value.forEach(cert => {
|
|
|
|
|
let isExpired = false;
|
2026-01-12 11:43:22 +03:00
|
|
|
// Backend now returns 'state' too, but we stick to days logic as per snippet
|
2026-01-09 10:30:49 +03:00
|
|
|
if (cert.days_remaining && typeof cert.days_remaining === 'string' && cert.days_remaining.includes('Expired')) {
|
|
|
|
|
isExpired = true;
|
2026-01-12 11:43:22 +03:00
|
|
|
} else if (cert.state === 'Expired') {
|
|
|
|
|
isExpired = true;
|
2026-01-09 10:30:49 +03:00
|
|
|
} 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isExpired) expired.push(cert);
|
|
|
|
|
else active.push(cert);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Sort logic from legacy
|
|
|
|
|
active.sort((a,b) => {
|
|
|
|
|
const aDays = parseInt(a.days_remaining) || 9999;
|
|
|
|
|
const bDays = parseInt(b.days_remaining) || 9999;
|
|
|
|
|
return aDays - bDays;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expired.sort((a,b) => {
|
|
|
|
|
const aMatch = (a.days_remaining||'').match(/\d+/);
|
|
|
|
|
const bMatch = (b.days_remaining||'').match(/\d+/);
|
|
|
|
|
const aDays = aMatch ? parseInt(aMatch[0]) : 0;
|
|
|
|
|
const bDays = bMatch ? parseInt(bMatch[0]) : 0;
|
|
|
|
|
return bDays - aDays;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { active, expired };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const activeCerts = computed(() => categorized.value.active);
|
|
|
|
|
const expiredCerts = computed(() => categorized.value.expired);
|
|
|
|
|
const totalCerts = computed(() => allCertificates.value.length);
|
|
|
|
|
const expiringCount = computed(() => {
|
|
|
|
|
return activeCerts.value.filter(c => {
|
|
|
|
|
const days = parseInt(c.days_remaining);
|
|
|
|
|
return !isNaN(days) && days <= 30;
|
|
|
|
|
}).length;
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-12 11:43:22 +03:00
|
|
|
const loadCerts = async () => {
|
2026-01-09 10:30:49 +03:00
|
|
|
loading.value = true;
|
|
|
|
|
try {
|
2026-01-12 11:43:22 +03:00
|
|
|
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 || [];
|
|
|
|
|
}
|
2026-01-09 10:30:49 +03:00
|
|
|
}
|
|
|
|
|
} catch(e) {
|
|
|
|
|
console.error(e);
|
2026-01-12 11:43:22 +03:00
|
|
|
Swal.fire('Error', 'Failed to load certificates', 'error');
|
2026-01-09 10:30:49 +03:00
|
|
|
} finally {
|
|
|
|
|
loading.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-12 11:43:22 +03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-09 10:30:49 +03:00
|
|
|
onMounted(() => {
|
2026-01-12 11:43:22 +03:00
|
|
|
loadCerts();
|
2026-01-09 10:30:49 +03:00
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.stats-info {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}
|
|
|
|
|
.stat-item {
|
|
|
|
|
background: var(--bg-card);
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
border: 1px solid var(--border-color);
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
.stat-value {
|
|
|
|
|
font-size: 2rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--text-heading);
|
|
|
|
|
line-height: 1.2;
|
|
|
|
|
}
|
|
|
|
|
.stat-label {
|
|
|
|
|
color: var(--text-muted);
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
margin-top: 0.5rem;
|
|
|
|
|
}
|
|
|
|
|
.section-divider td {
|
|
|
|
|
background-color: var(--bg-body);
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: var(--text-muted);
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.5px;
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
}
|
2026-01-12 11:43:22 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-09 10:30:49 +03:00
|
|
|
</style>
|