main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
641 lines
20 KiB
Vue
641 lines
20 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Server Certificate</h2>
|
|
</div>
|
|
|
|
<p class="setting-description">
|
|
The TLS certificate PCs see when they connect to this site, whether it is
|
|
served by IIS or by the Docker stack. Its public key pin goes into the
|
|
Set-ShopDBServerPins.ps1 entry, and the certificate itself into the
|
|
trusted root entry for agents older than 1.20.
|
|
</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 spki-pin">
|
|
<label>Public key pin (SPKI SHA-256)</label>
|
|
<p v-if="cert.spkisha256" class="field-hint mono">{{ cert.spkisha256 }}</p>
|
|
<p v-else class="field-hint">Not reported by this server.</p>
|
|
<button
|
|
v-if="cert.spkisha256"
|
|
class="btn btn-secondary btn-sm"
|
|
@click="copyPin"
|
|
>
|
|
{{ pinCopied ? 'Copied' : 'Copy pin' }}
|
|
</button>
|
|
<p class="field-hint">
|
|
Paste this into the GE-Enforce Set-ShopDBServerPins.ps1 entry
|
|
(-Pins) so agents 1.20 and later trust only this server's key.
|
|
It survives a renewal that keeps the same private key.
|
|
</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>
|
|
|
|
<!-- Live check: what the public address really presents -->
|
|
<section class="settings-section">
|
|
<h3>What PCs receive</h3>
|
|
<p class="field-hint">
|
|
Connects to this site's base URL and reads the certificate it presents,
|
|
the way a PC does. An IIS binding or a proxy in front of the app can
|
|
present a different certificate than the file above.
|
|
</p>
|
|
<div class="card form-card">
|
|
<div class="form-group live-check">
|
|
<label>Presented public key pin (SPKI SHA-256)</label>
|
|
<p v-if="liveLoading" class="field-hint">Checking {{ live?.url || 'the site address' }}...</p>
|
|
<template v-else-if="live">
|
|
<p v-if="!live.configured" class="field-hint live-unconfigured">
|
|
No site base URL is set (site_base_url, under Settings, Email /
|
|
SMTP, or in the setup wizard), so there is no address to check.
|
|
</p>
|
|
<template v-else>
|
|
<p class="field-hint">Checked <span class="mono">{{ live.url }}</span></p>
|
|
<p v-if="live.error" class="live-error">{{ live.error }}</p>
|
|
<template v-else>
|
|
<p class="field-hint mono" :class="live.matchesfile ? 'live-match' : 'live-mismatch'">
|
|
{{ live.presentedspkisha256 }}
|
|
</p>
|
|
<p v-if="live.matchesfile" class="live-match live-status">
|
|
Matches the pin of the certificate file above.
|
|
</p>
|
|
<div v-else class="live-warning">
|
|
<strong>The site presents a different certificate than the file above.</strong>
|
|
PCs will be pinned to the certificate they actually receive; use the
|
|
presented pin.
|
|
<div class="live-pins">
|
|
<span>File pin:</span>
|
|
<span class="mono">{{ cert?.spkisha256 || 'no certificate file' }}</span>
|
|
<span>Presented pin:</span>
|
|
<span class="mono">{{ live.presentedspkisha256 }}</span>
|
|
<span>Presented thumbprint:</span>
|
|
<span class="mono">{{ live.presentedsha1thumbprint }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
<button class="btn btn-secondary btn-sm live-recheck" :disabled="liveLoading" @click="loadLive">
|
|
{{ liveLoading ? 'Checking...' : 'Recheck' }}
|
|
</button>
|
|
</div>
|
|
</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="hostname or IP"
|
|
/>
|
|
<p class="field-hint">
|
|
Hostname or IP that agents use to connect, exactly as it appears in
|
|
the agent's base URL.
|
|
</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:hostname,IP:address"
|
|
/>
|
|
<p class="field-hint">
|
|
Comma-separated list of DNS:hostname and IP:address entries. Include
|
|
every name and address a bay may use to reach this server; a name
|
|
that is missing here fails certificate validation on the endpoint.
|
|
</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. A new certificate also has a new
|
|
key, so agents with ServerPins set will reject it until the pin entry carries the
|
|
new pin as well. Generating here gives no window to do that; to rotate without an
|
|
outage, compute the pin of a prepared certificate with Get-ShopDBServerPin.ps1, add
|
|
it to the pin entry alongside the old one, and upload that certificate below.
|
|
</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 live = ref(null)
|
|
const liveLoading = ref(false)
|
|
const thumbprintCopied = ref(false)
|
|
const pinCopied = ref(false)
|
|
|
|
// No baked host or address (ADR-015: no site literals in product code). One
|
|
// site's LAN address was shipped here as the default SAN, which every other
|
|
// site would have generated a certificate for. The operator types what this
|
|
// server is actually reached by; the form refuses to submit empty.
|
|
const generateForm = ref({
|
|
cn: '',
|
|
san: '',
|
|
days: 365
|
|
})
|
|
|
|
const uploadForm = ref({
|
|
certFile: null,
|
|
keyFile: null
|
|
})
|
|
|
|
onMounted(() => {
|
|
loadCertificate()
|
|
loadLive()
|
|
})
|
|
|
|
// What the site's public address actually presents. A proxy or IIS binding
|
|
// can serve a different certificate than the file this page manages, and a PC
|
|
// pins what it receives.
|
|
async function loadLive() {
|
|
liveLoading.value = true
|
|
try {
|
|
const response = await api.get('/endpoints/settings/server-cert/live')
|
|
live.value = response.data.data || null
|
|
} catch (err) {
|
|
live.value = {
|
|
configured: true,
|
|
url: live.value?.url || '',
|
|
error: `Could not run the check: ${err.message}`
|
|
}
|
|
} finally {
|
|
liveLoading.value = false
|
|
}
|
|
}
|
|
|
|
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()
|
|
loadLive()
|
|
} 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()
|
|
loadLive()
|
|
// 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 copyPin() {
|
|
navigator.clipboard.writeText(cert.value.spkisha256)
|
|
pinCopied.value = true
|
|
setTimeout(() => {
|
|
pinCopied.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;
|
|
}
|
|
|
|
.live-match { color: var(--success-dark); }
|
|
.live-mismatch { color: var(--danger); }
|
|
.live-status { font-size: 13px; margin: 4px 0; }
|
|
[data-theme="dark"] .live-match { color: var(--success); }
|
|
|
|
.live-error,
|
|
.live-warning {
|
|
margin: 8px 0;
|
|
padding: 10px 12px;
|
|
border-left: 3px solid var(--danger);
|
|
background: rgba(220, 53, 69, 0.1);
|
|
color: var(--danger);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.live-pins {
|
|
display: grid;
|
|
grid-template-columns: max-content 1fr;
|
|
gap: 4px 12px;
|
|
margin-top: 8px;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.info-list li {
|
|
margin-bottom: 12px;
|
|
}
|
|
</style>
|