Files
k8s-secrets/templates/index.html
T

200 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Секреты</title>
<style>
:root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #17233b; background: #f4f6fa; }
* { box-sizing: border-box; }
body { min-width: 320px; margin: 0; }
button { font: inherit; }
.page { width: min(100% - 48px, 1480px); margin: 0 auto; padding: 52px 0 88px; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 32px; }
.eyebrow { margin: 0 0 10px; color: #5c6c88; font-size: 11px; font-weight: 750; letter-spacing: .14em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(28px, 4vw, 38px); font-weight: 720; letter-spacing: -.04em; line-height: 1.1; }
.subtitle { margin: 10px 0 0; color: #687791; font-size: 14px; }
.count { flex: none; padding: 9px 13px; border: 1px solid #dce3ed; border-radius: 999px; background: #fff; color: #52617c; font-size: 12px; font-weight: 650; white-space: nowrap; }
.secrets-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 380px), 1fr)); gap: 20px; align-items: start; }
.secret-card { min-width: 0; overflow: hidden; border: 1px solid #dfe5ef; border-radius: 18px; background: #fff; box-shadow: 0 8px 28px rgba(31, 48, 82, .045); }
.secret-card.expired { border-color: #efc8c3; }
.card-header { padding: 22px 22px 18px; border-bottom: 1px solid #edf0f5; }
.card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.secret-name { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: 17px; font-weight: 700; letter-spacing: -.02em; line-height: 1.35; }
.source-badge, .expired-badge { display: inline-flex; align-items: center; gap: 6px; width: fit-content; border-radius: 999px; font-size: 11px; font-weight: 700; line-height: 1; white-space: nowrap; }
.source-badge { flex: none; padding: 7px 9px; }
.source-badge.k8s { background: #eaf0ff; color: #315cb3; }
.source-badge.webhook { background: #f0ebff; color: #6c47b6; }
.expired-badge { margin-top: 12px; padding: 7px 9px; background: #fff0ed; color: #ad453b; }
.fields { display: grid; gap: 10px; padding: 16px; }
.field { min-width: 0; padding: 13px; border: 1px solid #edf0f5; border-radius: 12px; background: #f8fafd; }
.field-key { display: block; margin-bottom: 10px; color: #62718b; font-size: 11px; font-weight: 700; letter-spacing: .035em; overflow-wrap: anywhere; }
.field-content { display: flex; align-items: center; gap: 10px; min-width: 0; }
.field-value { display: block; flex: 1; min-width: 0; overflow-wrap: anywhere; color: #263751; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; white-space: pre-wrap; }
.totp-code { color: #325dc0; font-size: 25px; font-weight: 750; letter-spacing: .08em; line-height: 1.15; white-space: nowrap; }
.totp-timer { flex: none; min-width: 30px; color: #6a7890; font-size: 11px; font-variant-numeric: tabular-nums; text-align: right; }
.copy-button { display: inline-flex; flex: none; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 0 10px; border: 1px solid #d8e0ec; border-radius: 8px; background: #fff; color: #445674; cursor: pointer; font-size: 11px; font-weight: 700; transition: background .15s, border-color .15s, color .15s; }
.copy-button:hover { border-color: #a9bce2; background: #eef3ff; color: #315cb3; }
.copy-button:focus-visible { outline: 3px solid #a9bce2; outline-offset: 2px; }
.copy-button.copied { border-color: #b9ddcc; background: #eaf7ef; color: #237a4c; }
.copy-button.failed { border-color: #efc8c3; background: #fff0ed; color: #ad453b; }
.copy-button svg { width: 14px; height: 14px; }
.card-footer { display: grid; gap: 5px; padding: 0 22px 20px; color: #73819a; font-size: 11px; line-height: 1.4; }
.card-footer strong { color: #596b86; font-weight: 600; }
.notice { padding: 25px; border: 1px solid #dfe5ef; border-radius: 16px; background: #fff; color: #60708b; font-size: 14px; }
.notice.error { border-color: #efc8c3; background: #fff7f5; color: #a43c34; }
@media (max-width: 560px) {
.page { width: min(100% - 28px, 1480px); padding-top: 32px; }
.page-header { align-items: flex-start; margin-bottom: 24px; }
.count { margin-top: 4px; }
.card-header { padding: 19px 18px 16px; }
.fields { padding: 12px; }
.card-footer { padding: 0 18px 18px; }
}
@media (max-width: 390px) {
.page-header { flex-wrap: wrap; }
.field-content { flex-wrap: wrap; }
.copy-button { margin-left: auto; }
}
</style>
</head>
<body>
<main class="page">
<header class="page-header">
<div>
<p class="eyebrow">Secret Reader</p>
<h1>Секреты</h1>
<p class="subtitle">Актуальные значения из Kubernetes и webhook</p>
</div>
<span class="count">Всего: {{ secrets.len() }}</span>
</header>
{% if let Some(err) = error %}
<div class="notice error" role="alert">Ошибка загрузки: {{ err }}</div>
{% else %}
{% if secrets.len() == 0 %}
<div class="notice">Пока нет секретов для отображения.</div>
{% else %}
<div class="secrets-grid">
{% for secret in secrets %}
<article class="secret-card{% if secret.expired %} expired{% endif %}">
<header class="card-header">
<div class="card-heading">
<h2 class="secret-name">{{ secret.name }}</h2>
{% match secret.source %}
{% when SecretSource::Kubernetes %}<span class="source-badge k8s">☸ Kubernetes</span>
{% when SecretSource::Webhook %}<span class="source-badge webhook">↗ Webhook</span>
{% endmatch %}
</div>
{% if secret.expired %}
{% if let Some(expires) = secret.expires_at %}<span class="expired-badge">Истёк: {{ expires }}</span>{% endif %}
{% endif %}
</header>
<div class="fields">
{% for (key, value) in secret.data %}
<div class="field">
<span class="field-key">{{ key }}</span>
<div class="field-content">
{% if value.starts_with("otpauth://totp/") %}
<span class="field-value totp-code" data-totp-url="{{ value }}" aria-label="Одноразовый код">••• •••</span>
<span class="totp-timer" aria-label="Осталось секунд"></span>
{% else %}
<code class="field-value">{{ value }}</code>
{% endif %}
<button class="copy-button" type="button" aria-label="Копировать {{ key }}" title="Копировать {{ key }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>
<span>Копировать</span>
</button>
</div>
</div>
{% endfor %}
</div>
{% if let Some(received) = secret.received_at %}
<footer class="card-footer">
<span>Получен: <strong>{{ received }}</strong></span>
{% if let Some(expires) = secret.expires_at %}
{% if !secret.expired %}<span>Истекает: <strong>{{ expires }}</strong></span>{% endif %}
{% endif %}
</footer>
{% endif %}
</article>
{% endfor %}
</div>
{% endif %}
{% endif %}
</main>
<script src="https://unpkg.com/otpauth@9/dist/otpauth.umd.min.js"></script>
<script>
const totpFields = [...document.querySelectorAll('[data-totp-url]')];
function updateTOTP() {
const now = Math.floor(Date.now() / 1000);
for (const codeElement of totpFields) {
const timerElement = codeElement.nextElementSibling;
try {
const totp = OTPAuth.URI.parse(codeElement.dataset.totpUrl);
const code = totp.generate();
codeElement.dataset.copyValue = code;
codeElement.textContent = code.replace(/(.{3})(?=.)/, '$1 ');
timerElement.textContent = (totp.period - (now % totp.period)) + 'с';
} catch (error) {
codeElement.textContent = 'Ошибка кода';
timerElement.textContent = '';
console.error('Failed to generate TOTP:', error);
}
}
}
async function writeClipboard(value) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
return;
}
const input = document.createElement('textarea');
input.value = value;
input.style.position = 'fixed';
input.style.opacity = '0';
document.body.appendChild(input);
input.select();
try {
if (!document.execCommand('copy')) throw new Error('Clipboard unavailable');
} finally {
input.remove();
}
}
document.addEventListener('click', async (event) => {
const button = event.target.closest('.copy-button');
if (!button) return;
const valueElement = button.closest('.field').querySelector('.field-value');
const value = valueElement.dataset.totpUrl ? valueElement.dataset.copyValue : valueElement.textContent;
if (!value) return;
const label = button.querySelector('span');
clearTimeout(button.resetTimeout);
button.classList.remove('copied', 'failed');
try {
await writeClipboard(value);
button.classList.add('copied');
label.textContent = 'Скопировано';
} catch (error) {
button.classList.add('failed');
label.textContent = 'Ошибка';
console.error('Failed to copy value:', error);
}
button.resetTimeout = setTimeout(() => {
button.classList.remove('copied', 'failed');
label.textContent = 'Копировать';
}, 2000);
});
if (totpFields.length > 0) {
updateTOTP();
setInterval(updateTOTP, 1000);
}
</script>
</body>
</html>