Files
shopdb-flask/plugins/endpoints/frontend/views/ServerCertSettings.vue
Proudlock 1a7bb9bd34 Fix code block still showing white text in dark mode (!important)
UX Fix: Code block contrast in dark mode

Problem:
- Code block with JSON example still white-on-white in dark mode
- User reported: 'still white text on white bg'
- Previous dark mode fix didn't override parent styles

Root Cause:
- Parent .field-hint and .info-list classes override .code-block
- Need !important to force dark mode colors
- <pre> tag selector needed more specificity

Solution:
- Added !important to dark mode color rules
- Added explicit selectors for pre.code-block
- Added nested selectors: .info-list .code-block, .field-hint .code-block
- Ensures dark mode applies regardless of parent context

Dark Mode Colors (enforced):
- background: #2b2b2b (dark gray)
- color: #e0e0e0 (light gray)
- border: #444 (medium gray)

Testing:
- Run: node scripts/stage-frontend.mjs
- Check: JSON code block in 'Agent certificate pinning' section
- Verify: Dark background, light text, readable in dark mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-09-14 15:48:52 -04:00

505 lines
14 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>Server Certificate</h2>
</div>
<p class="setting-description">
Manage the TLS certificate that nginx uses for HTTPS (port 5001).
Agents validate this certificate using Windows trusted roots or
thumbprint pinning (ServerCertThumbprint in appsettings.json).
</p>
<!-- Certificate status -->
<section class="settings-section">
<h3>Current certificate</h3>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="loading" class="field-hint">Loading certificate details...</div>
<template v-else-if="cert">
<div v-if="cert.expires_in_days < 30" class="warning-banner">
<strong>Warning:</strong> Certificate expires in {{ cert.expires_in_days }} days
({{ formatDate(cert.not_after) }}). Renew soon to avoid service disruption.
</div>
<div class="form-group">
<label>Common Name (CN)</label>
<p class="field-hint mono">{{ cert.subject_cn }}</p>
</div>
<div class="form-group">
<label>Subject Alternative Names</label>
<p class="field-hint mono">{{ cert.san_list.join(', ') }}</p>
</div>
<div class="form-group">
<label>Valid From</label>
<p class="field-hint">{{ formatDate(cert.not_before) }}</p>
</div>
<div class="form-group">
<label>Valid Until</label>
<p class="field-hint" :class="{ 'expiry-warning': cert.expires_in_days < 30 }">
{{ formatDate(cert.not_after) }}
<span v-if="cert.expires_in_days >= 0">
({{ cert.expires_in_days }} days remaining)
</span>
<span v-else class="expired-text">
(EXPIRED {{ Math.abs(cert.expires_in_days) }} days ago)
</span>
</p>
</div>
<div class="form-group">
<label>Thumbprint (SHA256)</label>
<p class="field-hint mono">{{ cert.thumbprint }}</p>
<button class="btn btn-secondary btn-sm" @click="copyThumbprint">
{{ thumbprintCopied ? 'Copied' : 'Copy thumbprint' }}
</button>
<p class="field-hint">
Use this value for ServerCertThumbprint in agent appsettings.json
to enable certificate pinning (prevents MITM attacks).
</p>
</div>
<div class="form-group">
<label>Issuer</label>
<p class="field-hint mono">{{ cert.issuer }}</p>
<span v-if="cert.is_self_signed" class="badge">Self-signed</span>
</div>
</template>
<template v-else>
<p class="field-hint">No certificate file found at certs/server.crt</p>
</template>
</div>
</section>
<!-- Generate self-signed certificate -->
<section class="settings-section">
<h3>Generate self-signed certificate</h3>
<p class="field-hint">
Create a new self-signed certificate for development or air-gapped sites.
The certificate will be valid for 1 year (365 days).
</p>
<div class="card form-card">
<div class="form-group">
<label for="cn">Common Name (CN)</label>
<input
id="cn"
v-model="generateForm.cn"
type="text"
class="form-control"
placeholder="localhost"
/>
<p class="field-hint">
Hostname or IP that agents use to connect (e.g., shopdb.example.com or 192.168.100.1)
</p>
</div>
<div class="form-group">
<label for="san">Subject Alternative Names (SANs)</label>
<input
id="san"
v-model="generateForm.san"
type="text"
class="form-control"
placeholder="DNS:localhost,IP:192.168.100.1"
/>
<p class="field-hint">
Comma-separated list: DNS:hostname or IP:address (e.g., DNS:localhost,IP:192.168.100.1,DNS:shopdb.local)
</p>
</div>
<div class="form-group">
<label for="days">Validity (days)</label>
<input
id="days"
v-model.number="generateForm.days"
type="number"
class="form-control"
min="1"
max="3650"
/>
<p class="field-hint">
Certificate lifetime (recommended: 365 for self-signed)
</p>
</div>
<div class="warning-banner" v-if="cert">
<strong>Warning:</strong> Generating a new certificate will replace the current one.
Agents using ServerCertThumbprint pinning will reject connections until you update
their appsettings.json with the new thumbprint.
</div>
<div class="button-row">
<button
class="btn btn-primary"
@click="generateCertificate"
:disabled="busy || !generateForm.cn"
>
{{ busy ? 'Generating...' : 'Generate self-signed certificate' }}
</button>
</div>
</div>
</section>
<!-- Upload certificate -->
<section class="settings-section">
<h3>Upload certificate</h3>
<p class="field-hint">
Upload a certificate from a trusted CA or internal PKI. You must provide
both the certificate (.crt or .pem) and private key (.key).
</p>
<div class="card form-card">
<div class="form-group">
<label for="cert-file">Certificate file</label>
<input
id="cert-file"
type="file"
accept=".crt,.pem"
@change="onCertFileChange"
class="form-control"
/>
<p class="field-hint">
PEM-encoded certificate (.crt or .pem)
</p>
</div>
<div class="form-group">
<label for="key-file">Private key file</label>
<input
id="key-file"
type="file"
accept=".key,.pem"
@change="onKeyFileChange"
class="form-control"
/>
<p class="field-hint">
PEM-encoded private key (.key or .pem, unencrypted)
</p>
</div>
<div class="warning-banner" v-if="cert">
<strong>Warning:</strong> Uploading a new certificate will replace the current one.
Nginx will be restarted automatically.
</div>
<div class="button-row">
<button
class="btn btn-primary"
@click="uploadCertificate"
:disabled="busy || !uploadForm.certFile || !uploadForm.keyFile"
>
{{ busy ? 'Uploading...' : 'Upload certificate' }}
</button>
</div>
</div>
</section>
<!-- Documentation -->
<section class="settings-section">
<div class="card form-card">
<details class="collapsible">
<summary><h4>Certificate requirements</h4></summary>
<ul class="field-hint info-list">
<li>
<strong>Format:</strong> PEM-encoded X.509 certificate and RSA/ECDSA private key
</li>
<li>
<strong>Private key:</strong> Must be unencrypted (no passphrase)
</li>
<li>
<strong>Common Name:</strong> Must match the hostname agents use to connect
</li>
<li>
<strong>Subject Alternative Names:</strong> Include all hostnames and IPs agents might use
</li>
<li>
<strong>Trust:</strong> Self-signed certs require ServerCertThumbprint pinning on agents,
or agents must disable validation (development only)
</li>
</ul>
</details>
<details class="collapsible">
<summary><h4>Agent certificate pinning</h4></summary>
<p class="field-hint">
To prevent MITM attacks with self-signed certificates, configure agents to validate
the server certificate thumbprint:
</p>
<ol class="field-hint info-list">
<li>Copy the SHA256 thumbprint from the "Current certificate" section above</li>
<li>Update each agent's appsettings.json:
<pre class="code-block">{
"ServerCertThumbprint": "AA:BB:CC:..."
}</pre>
</li>
<li>Restart the ShopDbEndpointAgent service on each agent</li>
</ol>
<p class="field-hint">
<strong>Important:</strong> When you renew the server certificate, you must update
ServerCertThumbprint on all agents before the old certificate expires, or agents
will reject connections.
</p>
</details>
<details class="collapsible">
<summary><h4>Air-gapped deployment</h4></summary>
<p class="field-hint">
For air-gapped sites, generate the certificate on the work PC and include it in
the deployment bundle:
</p>
<ol class="field-hint info-list">
<li>Generate certificate using this page (use site-specific CN/SANs)</li>
<li>Certificate files are saved to certs/ directory (already in git)</li>
<li>Commit and bundle the code (certificate included automatically)</li>
<li>Deploy bundle to site - nginx picks up certificate on startup</li>
</ol>
</details>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
const loading = ref(false)
const busy = ref(false)
const message = ref('')
const error = ref('')
const cert = ref(null)
const thumbprintCopied = ref(false)
const generateForm = ref({
cn: 'localhost',
san: 'DNS:localhost,IP:192.168.100.1',
days: 365
})
const uploadForm = ref({
certFile: null,
keyFile: null
})
onMounted(() => {
loadCertificate()
})
async function loadCertificate() {
loading.value = true
error.value = ''
try {
const response = await api.get('/endpoints/settings/server-cert')
if (response.data.status === 'success') {
cert.value = response.data.data
} else {
error.value = response.data.data?.error?.message || 'Failed to load certificate'
}
} catch (err) {
error.value = `Failed to load certificate: ${err.message}`
} finally {
loading.value = false
}
}
async function generateCertificate() {
if (!generateForm.value.cn) {
error.value = 'Common Name is required'
return
}
if (!confirm(
'Generate a new self-signed certificate? This will replace the current certificate. ' +
'Agents using thumbprint pinning will need to be reconfigured.'
)) {
return
}
busy.value = true
error.value = ''
message.value = ''
try {
const response = await api.post('/endpoints/settings/server-cert/generate', generateForm.value)
if (response.data.status === 'success') {
message.value = 'Certificate generated successfully. Nginx is restarting...'
await loadCertificate()
} else {
error.value = response.data.data?.error?.message || 'Failed to generate certificate'
}
} catch (err) {
error.value = `Failed to generate certificate: ${err.message}`
} finally {
busy.value = false
}
}
function onCertFileChange(event) {
uploadForm.value.certFile = event.target.files[0]
}
function onKeyFileChange(event) {
uploadForm.value.keyFile = event.target.files[0]
}
async function uploadCertificate() {
if (!uploadForm.value.certFile || !uploadForm.value.keyFile) {
error.value = 'Both certificate and private key files are required'
return
}
if (!confirm(
'Upload new certificate? This will replace the current certificate and restart nginx.'
)) {
return
}
busy.value = true
error.value = ''
message.value = ''
try {
const formData = new FormData()
formData.append('cert', uploadForm.value.certFile)
formData.append('key', uploadForm.value.keyFile)
const response = await api.post('/endpoints/settings/server-cert/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
if (response.data.status === 'success') {
message.value = 'Certificate uploaded successfully. Nginx is restarting...'
await loadCertificate()
// Clear file inputs
document.getElementById('cert-file').value = ''
document.getElementById('key-file').value = ''
uploadForm.value.certFile = null
uploadForm.value.keyFile = null
} else {
error.value = response.data.data?.error?.message || 'Failed to upload certificate'
}
} catch (err) {
error.value = `Failed to upload certificate: ${err.message}`
} finally {
busy.value = false
}
}
function copyThumbprint() {
navigator.clipboard.writeText(cert.value.thumbprint)
thumbprintCopied.value = true
setTimeout(() => {
thumbprintCopied.value = false
}, 2000)
}
function formatDate(dateString) {
if (!dateString) return 'N/A'
const date = new Date(dateString)
return date.toLocaleString()
}
</script>
<style scoped>
.warning-banner {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
color: #856404;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.warning-banner {
background: #664d03;
border-color: #ffca2c;
color: #ffecb5;
}
}
.expiry-warning {
color: #d63384;
font-weight: 600;
}
.expired-text {
color: #dc3545;
font-weight: bold;
}
.badge {
display: inline-block;
padding: 4px 8px;
background: #6c757d;
color: white;
border-radius: 4px;
font-size: 12px;
margin-left: 8px;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 13px;
}
.btn-sm {
padding: 4px 12px;
font-size: 13px;
margin-top: 8px;
}
.code-block,
pre.code-block {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 12px;
margin: 8px 0;
font-family: 'Courier New', monospace;
font-size: 13px;
overflow-x: auto;
color: #212529;
white-space: pre;
}
/* Dark mode support for code blocks */
@media (prefers-color-scheme: dark) {
.code-block {
background: #2b2b2b !important;
border-color: #444 !important;
color: #e0e0e0 !important;
}
/* Ensure pre tags inside lists also get dark mode */
.info-list .code-block,
.field-hint .code-block,
pre.code-block {
background: #2b2b2b !important;
border-color: #444 !important;
color: #e0e0e0 !important;
}
}
.info-list {
margin-left: 20px;
}
.info-list li {
margin-bottom: 12px;
}
</style>