Files
Bridge-and-Join-s/cmd/bj-license-server/web/pages.html
T
zuevav 86aa452ed6 feat(license): сервер лицензий lic.zetit.ru — кабинет, онлайн-активация и защита
- bj-license-server: активация по коду и продление токенов (API /v1), кабинет
  администратора (клиенты, лицензии, установки, журнал, администраторы)
- защита: доверенные IP установок, jail fail2ban api/admin, раздел «Защита»
  с режимом обороны, белым списком и сроками блокировок (root-помощник fw-apply)
- доступ к кабинету: свой адрес вместо /admin, вход только из белого списка,
  второй фактор — TOTP или подтверждение в «ЗЕТИТ Ключ» (push/код)
- deploy/license: unit-файлы, fail2ban, path/timer помощника; контракт API в docs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 19:41:11 +03:00

485 lines
52 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.
{{define "style"}}<style>
:root{--bg:#f4f5f9;--card:#fff;--card2:#f8f9fc;--text:#1a1d26;--muted:#6b7280;--accent:#3b4fd8;--accent2:#e8562a;--border:#e3e5ee;--ok:#15803d;--warn:#b45309;--bad:#b91c1c;--okbg:#e8f6ee;--warnbg:#fdf3e2;--badbg:#fdecec}
@media (prefers-color-scheme:dark){:root{--bg:#131620;--card:#1c202d;--card2:#232838;--text:#e6e8f0;--muted:#9aa0b3;--accent:#8190ff;--border:#2d3244;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171;--okbg:#16301f;--warnbg:#33280f;--badbg:#3a1a1a}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
header{background:var(--card);border-bottom:1px solid var(--border);padding:0 20px;display:flex;align-items:center;gap:24px;flex-wrap:wrap;min-height:56px}
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:16px;white-space:nowrap;color:var(--text)}
.brand em{font-style:normal;color:var(--accent)}.brand small{font-weight:500;color:var(--muted);font-size:13px}
nav{display:flex;gap:4px;flex-wrap:wrap;flex:1}
nav a{padding:7px 11px;border-radius:8px;color:var(--muted);font-weight:500}
nav a.on{background:var(--card2);color:var(--text)}nav a:hover{text-decoration:none;color:var(--text)}
.me{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:13px}
main{max-width:1180px;margin:0 auto;padding:22px 20px 60px}
h1{font-size:22px;margin:0 0 16px}h2{font-size:16px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin-bottom:16px}
.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
.stats{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:16px}
.stat{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 16px}
.stat b{display:block;font-size:26px;line-height:1.2}.stat span{color:var(--muted);font-size:13px}
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse}
th{text-align:left;font-size:12px;font-weight:600;color:var(--muted);padding:8px 10px;border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:9px 10px;border-bottom:1px solid var(--border);vertical-align:top}
tr:last-child td{border-bottom:0}
.muted{color:var(--muted)}.small{font-size:12px}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600;white-space:nowrap}
.badge.ok{background:var(--okbg);color:var(--ok)}.badge.warn{background:var(--warnbg);color:var(--warn)}.badge.bad{background:var(--badbg);color:var(--bad)}
.chip{display:inline-block;padding:1px 7px;margin:1px 2px 1px 0;border:1px solid var(--border);border-radius:6px;font-size:12px;background:var(--card2)}
.flash{padding:10px 14px;border-radius:10px;margin-bottom:16px;font-weight:500}
.flash.ok{background:var(--okbg);color:var(--ok)}.flash.err{background:var(--badbg);color:var(--bad)}
form.f{display:grid;gap:12px}
label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}
input[type=text],input[type=password],input[type=number],input[type=date],select,textarea{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font:inherit}
textarea{min-height:64px;resize:vertical}
input:focus,select:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:1px;border-color:transparent}
.row2{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(200px,1fr))}
.checks{display:flex;flex-wrap:wrap;gap:6px 16px}.checks label{display:flex;gap:6px;align-items:center;font-size:14px;color:var(--text);margin:0}
.btn{display:inline-block;padding:8px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font:inherit;font-weight:600;cursor:pointer}
.btn:hover{background:var(--card2);text-decoration:none}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}.btn.primary:hover{filter:brightness(1.07)}
.btn.danger{color:var(--bad)}.btn.sm{padding:4px 9px;font-size:12px}
.actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.code{font:600 20px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.06em;padding:10px 14px;background:var(--card2);border:1px dashed var(--border);border-radius:10px;display:inline-block;user-select:all}
.token{font:12px/1.4 ui-monospace,monospace;word-break:break-all;background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:10px;user-select:all}
.kv{display:grid;grid-template-columns:minmax(140px,max-content) 1fr;gap:6px 16px}.kv dt{color:var(--muted)}.kv dd{margin:0}
details summary{cursor:pointer;font-weight:600}
.mono{font-family:ui-monospace,monospace;font-size:12px}
.none::before{content:"\2014";color:var(--muted)} /* пустое значение */
@media (max-width:640px){header{padding:8px 16px;gap:8px}main{padding:16px}.kv{grid-template-columns:1fr}}
</style>{{end}}
{{define "logo"}}<svg width="24" height="24" viewBox="0 0 26 26" fill="none" aria-hidden="true"><path d="M5.5 16 C 9 8.5, 17 8.5, 20.5 16" stroke="#3b4fd8" stroke-width="1.9" stroke-linecap="round"/><circle cx="5.5" cy="16" r="3.1" fill="#3b4fd8"/><circle cx="20.5" cy="16" r="3.1" fill="#e8562a"/></svg>{{end}}
{{define "head"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · лицензии Bridge&amp;Join</title>{{template "style"}}</head><body>
<header>
<a class="brand" href="{{adm}}/">{{template "logo"}}<span>Bridge<em>&amp;Join</em></span><small>лицензии</small></a>
<nav>
<a href="{{adm}}/" {{if eq .Nav "dash"}}class="on"{{end}}>Обзор</a>
<a href="{{adm}}/tenants" {{if eq .Nav "tenants"}}class="on"{{end}}>Клиенты</a>
<a href="{{adm}}/licenses" {{if eq .Nav "licenses"}}class="on"{{end}}>Лицензии</a>
<a href="{{adm}}/installs" {{if eq .Nav "installs"}}class="on"{{end}}>Установки</a>
<a href="{{adm}}/security" {{if eq .Nav "security"}}class="on"{{end}}>Защита</a>
<a href="{{adm}}/events" {{if eq .Nav "events"}}class="on"{{end}}>Журнал</a>
<a href="{{adm}}/users" {{if eq .Nav "users"}}class="on"{{end}}>Администраторы</a>
</nav>
<div class="me">{{if .Me}}<span>{{if .Me.Name}}{{.Me.Name}}{{else}}{{.Me.Login}}{{end}}</span>
<form method="post" action="{{adm}}/logout" style="margin:0"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="btn sm" type="submit">Выйти</button></form>{{end}}</div>
</header>
<main>
{{if .OK}}<div class="flash ok">{{.OK}}</div>{{end}}
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
{{end}}
{{define "foot"}}</main></body></html>{{end}}
{{define "login"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Вход · лицензии Bridge&amp;Join</title>{{template "style"}}</head>
<body style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px">
<div class="card" style="width:100%;max-width:380px;padding:28px 30px">
<div class="brand" style="font-size:20px;margin-bottom:4px">{{template "logo"}}<span>Bridge<em>&amp;Join</em></span></div>
<p class="muted" style="margin:0 0 18px;font-size:13px">Сервер лицензирования: вход администратора</p>
<form class="f" method="post" action="{{adm}}/login">
<input type="hidden" name="next" value="{{.Data.Next}}">
<div><label for="login">Логин</label><input id="login" type="text" name="login" value="{{.Data.Login}}" autocomplete="username" autofocus required></div>
<div><label for="password">Пароль</label><input id="password" type="password" name="password" autocomplete="current-password" required></div>
<div><label for="code">Код из приложения <span class="muted" style="font-weight:400">(если подключён второй фактор)</span></label><input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6"></div>
<button class="btn primary" type="submit" style="width:100%;padding:10px">Войти</button>
{{if .Err}}<div class="flash err" style="margin:0">{{.Err}}</div>{{end}}
</form>
</div></body></html>{{end}}
{{define "login2fa"}}<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Подтверждение входа · лицензии Bridge&amp;Join</title>{{template "style"}}</head>
<body style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:16px">
<div class="card" style="width:100%;max-width:400px;padding:28px 30px">
<div class="brand" style="font-size:20px;margin-bottom:4px">{{template "logo"}}<span>Bridge<em>&amp;Join</em></span></div>
<h2 style="margin:10px 0 6px">Подтвердите вход</h2>
<p class="muted" style="margin:0 0 6px;font-size:13px" id="zk-msg">Запрос отправлен в приложение «ЗЕТИТ Ключ» ({{.Data.Email}}). Подтвердите вход на телефоне — страница продолжит сама.</p>
<p style="font-size:30px;font-weight:700;margin:4px 0 14px" id="zk-count">{{.Data.Left}}</p>
<form id="zk-finish" method="post" action="{{adm}}/login/2fa/finish"><input type="hidden" name="t" value="{{.Data.Token}}"></form>
<form class="f" method="post" action="{{adm}}/login/2fa/code">
<input type="hidden" name="t" value="{{.Data.Token}}">
<div><label for="code">Или введите код из приложения</label><input id="code" type="text" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></div>
<button class="btn primary" type="submit" style="width:100%;padding:10px">Войти с кодом</button>
{{if .Err}}<div class="flash err" style="margin:0">{{.Err}}</div>{{end}}
</form>
<p class="small" style="margin:14px 0 0"><a href="{{adm}}/login">Отмена</a></p>
</div>
<script>
(function(){
var token = {{.Data.Token}}, left = {{.Data.Left}}, base = {{adm}}, done = false;
var cnt = document.getElementById('zk-count'), msg = document.getElementById('zk-msg');
function expired(){ msg.textContent = 'Время подтверждения истекло. Введите код из приложения или войдите заново.'; }
var tick = setInterval(function(){
left = Math.max(0, left - 1); cnt.textContent = left;
if (left === 0) { clearInterval(tick); if (!done) expired(); }
}, 1000);
function poll(){
if (done || left <= 0) return;
fetch(base + '/login/2fa/status?t=' + encodeURIComponent(token), {credentials: 'same-origin', cache: 'no-store'})
.then(function(r){ return r.json(); })
.then(function(j){
if (j.status === 'approved' || j.status === 'denied') {
done = true;
msg.textContent = j.status === 'approved' ? 'Вход подтверждён.' : 'Вход отклонён в приложении.';
document.getElementById('zk-finish').submit();
return;
}
if (j.status === 'expired') { done = true; expired(); return; }
setTimeout(poll, 2500);
})
.catch(function(){ setTimeout(poll, 4000); });
}
if (left > 0) setTimeout(poll, 2000); else expired();
})();
</script></body></html>{{end}}
{{define "evtable"}}<div class="table-wrap"><table>
<thead><tr><th>Когда</th><th>Событие</th><th>Клиент</th><th>Кто</th><th>IP</th></tr></thead><tbody>
{{range .}}<tr>
<td class="small" style="white-space:nowrap">{{dt .At}}</td>
<td>{{.Text}}{{if .InstallID}} <span class="mono muted">{{short .InstallID}}</span>{{end}}</td>
<td>{{if .LicenseID}}<a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a>{{else}}<span class="none"></span>{{end}}</td>
<td class="small">{{.Actor}}</td><td class="small mono">{{.IP}}</td>
</tr>{{else}}<tr><td colspan="5" class="muted">Событий пока нет</td></tr>{{end}}
</tbody></table></div>{{end}}
{{define "dashboard"}}{{template "head" .}}
<h1>Обзор</h1>
<div class="stats">
<div class="stat"><b>{{.Data.Tenants}}</b><span>клиентов</span></div>
<div class="stat"><b>{{.Data.Active}}</b><span>действующих лицензий</span></div>
<div class="stat"><b>{{.Data.Revoked}}</b><span>отозвано</span></div>
<div class="stat"><b>{{.Data.Installs}}</b><span>установок всего</span></div>
<div class="stat"><b>{{.Data.Online}}</b><span>на связи за 48 ч</span></div>
</div>
{{if .Data.Expiring}}<div class="card"><h2>Требуют внимания</h2><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>План</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Expiring}}<tr><td><a href="{{adm}}/licenses/{{.ID}}">{{.TenantName}}</a></td><td>{{plan .Plan}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td></tr>{{end}}
</tbody></table></div></div>{{end}}
<div class="card"><h2>Последние события</h2>{{template "evtable" .Data.Events}}<p class="small" style="margin:10px 0 0"><a href="{{adm}}/events">Весь журнал →</a></p></div>
{{template "foot" .}}{{end}}
{{define "tenants"}}{{template "head" .}}
<h1>Клиенты</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>ИНН</th><th>Контакт</th><th>Лицензий</th><th>Установок</th><th>Добавлен</th></tr></thead><tbody>
{{range .Data}}<tr><td><a href="{{adm}}/tenants/{{.ID}}"><b>{{.Name}}</b></a></td><td class="mono">{{.INN}}</td><td class="small">{{.Contact}}</td><td>{{.Licenses}}</td><td>{{.Installs}}</td><td class="small">{{d .CreatedAt}}</td></tr>
{{else}}<tr><td colspan="6" class="muted">Клиентов пока нет. Добавьте первого ниже.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Новый клиент</h2>
<form class="f" method="post" action="{{adm}}/tenants"><input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="row2">
<div><label>Название</label><input type="text" name="name" required placeholder="АО «ИК Фонтвьель»"></div>
<div><label>ИНН</label><input type="text" name="inn" inputmode="numeric" pattern="\d{10}|\d{12}" placeholder="10 или 12 цифр"></div>
</div>
<div><label>Контакт</label><input type="text" name="contact" placeholder="ответственный, почта, телефон"></div>
<div><label>Примечание</label><textarea name="note"></textarea></div>
<div><button class="btn primary" type="submit">Добавить клиента</button></div>
</form></div>
{{template "foot" .}}{{end}}
{{define "tenant"}}{{template "head" .}}
<p class="small" style="margin:0 0 6px"><a href="{{adm}}/tenants">← Клиенты</a></p>
<h1>{{.Data.Tenant.Name}}</h1>
<div class="card"><div class="actions" style="justify-content:space-between;margin-bottom:10px"><h2 style="margin:0">Лицензии</h2><a class="btn primary" href="{{adm}}/licenses?tenant={{.Data.Tenant.ID}}#new">Выпустить лицензию</a></div>
<div class="table-wrap"><table>
<thead><tr><th>Лицензия</th><th>План</th><th>Установок</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Licenses}}<tr><td><a class="mono" href="{{adm}}/licenses/{{.ID}}">{{.Code}}</a></td><td>{{plan .Plan}}</td><td>{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td></tr>
{{else}}<tr><td colspan="5" class="muted">Лицензий нет</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Реквизиты</h2>
<form class="f" method="post" action="{{adm}}/tenants/{{.Data.Tenant.ID}}"><input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="row2">
<div><label>Название</label><input type="text" name="name" value="{{.Data.Tenant.Name}}" required></div>
<div><label>ИНН</label><input type="text" name="inn" value="{{.Data.Tenant.INN}}" inputmode="numeric" pattern="\d{10}|\d{12}"></div>
</div>
<div><label>Контакт</label><input type="text" name="contact" value="{{.Data.Tenant.Contact}}"></div>
<div><label>Примечание</label><textarea name="note">{{.Data.Tenant.Note}}</textarea></div>
<div class="actions"><button class="btn primary" type="submit">Сохранить</button><span class="muted small">добавлен {{dt .Data.Tenant.CreatedAt}}</span></div>
</form></div>
{{template "foot" .}}{{end}}
{{define "terms"}}
<div class="row2">
<div><label>План</label><select name="plan">{{$cur := .Plan}}{{range $.Plans}}<option value="{{.Key}}" {{if eq .Key $cur}}selected{{end}}>{{.Title}}</option>{{end}}</select></div>
<div><label>Установок (0 = без лимита)</label><input type="number" name="max_nodes" min="0" max="1000" value="{{.MaxNodes}}"></div>
<div><label>Договор действует до</label><input type="date" name="contract_until" value="{{dateInput .ContractUntil}}" required></div>
</div>
<div><label>Модули</label><div class="checks">{{$f := .Features}}{{range $.All}}<label><input type="checkbox" name="features" value="{{.Key}}" {{if has $f .Key}}checked{{end}}> {{.Title}}</label>{{end}}</div></div>
<div><label>Примечание (видно только здесь)</label><textarea name="note">{{.Note}}</textarea></div>
{{end}}
{{define "licenses"}}{{template "head" .}}
<h1>Лицензии</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>Код активации</th><th>План</th><th>Модули</th><th>Установок</th><th>Договор до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Rows}}<tr>
<td><a href="{{adm}}/licenses/{{.ID}}"><b>{{.TenantName}}</b></a></td><td class="mono">{{.Code}}</td><td>{{plan .Plan}}</td>
<td>{{range .Features}}<span class="chip">{{feature .}}</span>{{else}}<span class="none"></span>{{end}}</td>
<td>{{.Active}} из {{if .MaxNodes}}{{.MaxNodes}}{{else}}∞{{end}}</td><td>{{d .ContractUntil}}</td><td><span class="badge {{.StatusClass}}">{{.Status}}</span></td>
</tr>{{else}}<tr><td colspan="7" class="muted">Лицензий пока нет.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card" id="new"><h2>Выпустить лицензию</h2>
{{if .Data.Tenants}}
<form class="f" method="post" action="{{adm}}/licenses"><input type="hidden" name="csrf" value="{{.CSRF}}">
<div><label>Клиент</label><select name="tenant_id" required>{{$sel := .Data.Tenant}}{{range .Data.Tenants}}<option value="{{.ID}}" {{if eq .ID $sel}}selected{{end}}>{{.Name}}{{if .INN}} · ИНН {{.INN}}{{end}}</option>{{end}}</select></div>
{{template "terms" (dict2 .Plans .Features)}}
<div><button class="btn primary" type="submit">Выпустить и получить код активации</button></div>
</form>
{{else}}<p class="muted">Сначала <a href="{{adm}}/tenants">добавьте клиента</a>.</p>{{end}}
</div>
{{template "foot" .}}{{end}}
{{define "license"}}{{template "head" .}}{{$row := .Data.Row}}{{$csrf := .CSRF}}
<p class="small" style="margin:0 0 6px"><a href="{{adm}}/licenses">← Лицензии</a> · <a href="{{adm}}/tenants/{{$row.TenantID}}">{{$row.TenantName}}</a></p>
<h1>{{$row.TenantName}} <span class="badge {{$row.StatusClass}}" style="vertical-align:middle">{{$row.Status}}</span></h1>
{{if .Data.OfflineToken}}<div class="card" style="border-color:var(--accent)"><h2>Офлайн-ключ выпущен</h2>
<p class="muted small" style="margin-top:0">Ключ показывается один раз. Вставьте его в bj: Настройка → Лицензия. Действует до {{d $row.ContractUntil}} без связи с сервером; отзыв на него не распространяется.</p>
<div class="token">{{.Data.OfflineToken}}</div></div>{{end}}
<div class="grid">
<div class="card"><h2>Код активации</h2>
<div class="code">{{$row.Code}}</div>
<p class="muted small">Клиент вводит код в bj (Настройка → Лицензия → «Активировать»). Одним кодом активируется до {{if $row.MaxNodes}}{{$row.MaxNodes}}{{else}}любого числа{{end}} установок; сейчас активных {{$row.Active}}.</p>
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/code" onsubmit="return confirm('Выпустить новый код? Прежний перестанет действовать; активированные установки продолжат работать.')">
<input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Выпустить новый код</button></form>
</div>
<div class="card"><h2>Сводка</h2><dl class="kv">
<dt>План</dt><dd>{{plan $row.Plan}}</dd>
<dt>Модули</dt><dd>{{range $row.Features}}<span class="chip">{{feature .}}</span>{{else}}<span class="none"></span>{{end}}</dd>
<dt>Договор до</dt><dd>{{d $row.ContractUntil}}{{if not $row.Revoked}} <span class="muted small">(осталось {{daysLeft $row.ContractUntil}} дн.)</span>{{end}}</dd>
<dt>Выпущена</dt><dd>{{dt $row.CreatedAt}}{{if $row.CreatedBy}} · {{$row.CreatedBy}}{{end}}</dd>
<dt>ID</dt><dd class="mono">{{$row.ID}}</dd>
{{if $row.Revoked}}<dt>Отозвана</dt><dd>{{dt $row.RevokedAt}}: {{$row.RevokeReason}}</dd>{{end}}
</dl></div>
</div>
<div class="card"><h2>Установки</h2><div class="table-wrap"><table>
<thead><tr><th>Сервер</th><th>Версия</th><th>IP</th><th>Первая активация</th><th>Последнее продление</th><th>Токен до</th><th></th></tr></thead><tbody>
{{range .Data.Installs}}<tr>
<td><b>{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}</b><div class="mono muted">{{.InstallID}}</div>{{if .Blocked}}<span class="badge bad">заблокирована</span> <span class="small">{{.BlockReason}}</span>{{else if .Stale}}<span class="badge warn">нет связи</span>{{end}}</td>
<td class="small">{{.Version}}</td><td class="small mono">{{.IP}}</td><td class="small">{{dt .FirstSeen}}</td>
<td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td><td class="small">{{d .TokenExpires}}</td>
<td>{{if .Blocked}}<form method="post" action="{{adm}}/licenses/{{$row.ID}}/installs/{{.InstallID}}/unblock"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Разблокировать</button></form>
{{else}}<details><summary class="small">Заблокировать</summary><form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/installs/{{.InstallID}}/block" style="margin-top:6px;min-width:200px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="text" name="reason" placeholder="причина" required><button class="btn sm danger" type="submit">Заблокировать</button></form></details>{{end}}</td>
</tr>{{else}}<tr><td colspan="7" class="muted">Установок ещё нет: они появятся после активации кодом.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Условия</h2>
<form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/edit"><input type="hidden" name="csrf" value="{{$csrf}}">
{{template "terms" (dict3 .Plans .Features $row.License)}}
<div class="actions"><button class="btn primary" type="submit">Сохранить условия</button><span class="muted small">установки получат их при следующем продлении</span></div>
</form></div>
<div class="grid">
<div class="card"><h2>Офлайн-ключ</h2>
<p class="muted small" style="margin-top:0">Для закрытого контура без доступа к серверу: ключ без привязки к установке, действует до окончания договора. Отозвать его нельзя.</p>
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/offline" onsubmit="return confirm('Выпустить офлайн-ключ до {{d $row.ContractUntil}}? Отозвать его будет нельзя.')"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn" type="submit" {{if ne $row.StatusClass "ok"}}{{if ne $row.StatusClass "warn"}}disabled{{end}}{{end}}>Выпустить офлайн-ключ</button></form>
</div>
<div class="card"><h2>{{if $row.Revoked}}Восстановление{{else}}Отзыв{{end}}</h2>
{{if $row.Revoked}}
<form method="post" action="{{adm}}/licenses/{{$row.ID}}/restore"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn" type="submit">Восстановить лицензию</button></form>
{{else}}
<p class="muted small" style="margin-top:0">Продления прекращаются; выданные токены доживают свой срок (до 60 дней).</p>
<form class="f" method="post" action="{{adm}}/licenses/{{$row.ID}}/revoke" onsubmit="return confirm('Отозвать лицензию?')"><input type="hidden" name="csrf" value="{{$csrf}}">
<input type="text" name="reason" placeholder="причина отзыва" required><div><button class="btn danger" type="submit">Отозвать лицензию</button></div></form>
{{end}}
</div>
</div>
<div class="card"><h2>Журнал лицензии</h2>{{template "evtable" .Data.Events}}</div>
{{template "foot" .}}{{end}}
{{define "installs"}}{{template "head" .}}{{$csrf := .CSRF}}
<h1>Установки</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Клиент</th><th>Сервер</th><th>Версия</th><th>IP</th><th>Последнее продление</th><th>Токен до</th><th>Статус</th></tr></thead><tbody>
{{range .Data.Installs}}<tr>
<td><a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a></td>
<td><b>{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}</b><div class="mono muted">{{short .InstallID}}</div></td>
<td class="small">{{.Version}}</td><td class="small mono">{{.IP}}</td><td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td><td class="small">{{d .TokenExpires}}</td>
<td>{{if .Blocked}}<span class="badge bad">заблокирована</span>{{else if .Stale}}<span class="badge warn">нет связи</span>{{else}}<span class="badge ok">на связи</span>{{end}}</td>
</tr>{{else}}<tr><td colspan="7" class="muted">Установок пока нет.</td></tr>{{end}}
</tbody></table></div></div>
<div class="card"><h2>Доверенные IP (fail2ban)</h2>
<p class="muted small" style="margin:0 0 10px">Адрес попадает сюда после успешной активации или продления лицензии. fail2ban не блокирует его за ошибки API (неверный код, испорченный токен). На вход в кабинет и SSH доверие не распространяется. При отзыве лицензии или блокировке установки адрес исключается автоматически.</p>
<div class="table-wrap"><table>
<thead><tr><th>IP</th><th>Клиент</th><th>Сервер</th><th>Добавлен</th><th>Последнее обращение</th><th></th></tr></thead><tbody>
{{range .Data.Trusted}}<tr>
<td class="mono"><b>{{.IP}}</b></td>
<td><a href="{{adm}}/licenses/{{.LicenseID}}">{{if .TenantName}}{{.TenantName}}{{else}}{{short .LicenseID}}{{end}}</a></td>
<td class="small">{{if .Hostname}}{{.Hostname}}{{else}}<span class="none"></span>{{end}}<div class="mono muted">{{short .InstallID}}</div></td>
<td class="small">{{dt .AddedAt}}</td><td class="small">{{dt .LastSeen}}<div class="muted">{{ago .LastSeen}}</div></td>
<td><form method="post" action="{{adm}}/trusted/remove"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="ip" value="{{.IP}}"><input type="hidden" name="install" value="{{.InstallID}}"><button class="btn sm danger" type="submit">Исключить</button></form></td>
</tr>{{else}}<tr><td colspan="6" class="muted">Доверенных адресов пока нет: они появятся после первой активации или продления.</td></tr>{{end}}
</tbody></table></div></div>
{{template "foot" .}}{{end}}
{{define "security"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}}
<h1>Защита</h1>
<div class="card"><h2>Доступ к кабинету</h2>
<div class="grid">
<div>
<h3 style="margin:0 0 6px;font-size:15px">Адрес кабинета</h3>
<p style="margin:0 0 8px"><span class="mono"><b>https://{{$d.Host}}{{$d.AdminBase}}/</b></span> {{if $d.AdminDefault}}<span class="badge warn">стандартный</span>{{else}}<span class="badge ok">свой</span>{{end}}</p>
<form method="post" action="{{adm}}/security/access" class="actions" onsubmit="return confirm('Сменить адрес кабинета? Прежний перестанет открываться.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path"><input name="path" placeholder="свой адрес, 8–64 символа: a-z, 0-9, дефис" pattern="[a-z0-9][a-z0-9\-]{7,63}" required style="max-width:280px"><button class="btn" type="submit">Сохранить</button></form>
<div class="actions" style="margin-top:6px">
<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Сгенерировать новый адрес кабинета? Прежний перестанет открываться.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path-random"><button class="btn" type="submit">Сгенерировать случайный</button></form>
{{if not $d.AdminDefault}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Вернуть стандартный адрес /admin?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="path-default"><button class="btn sm" type="submit">Вернуть /admin</button></form>{{end}}
</div>
<p class="muted small" style="margin:8px 0 0">При своём адресе /admin и главная страница отвечают «не найдено», и боты не находят форму входа. Сохраните адрес в закладки и не пересылайте. Узнать по SSH: <span class="mono">/opt/bj-license/bj-license-server admin-info</span></p>
</div>
<div>
<h3 style="margin:0 0 6px;font-size:15px">Только из белого списка</h3>
<p style="margin:0 0 8px">{{if and $d.AdminAllowOnly $d.AdminOpen}}<span class="badge warn">открыт аварийно по SSH</span> Ограничение включено, но сейчас не действует.{{else if $d.AdminAllowOnly}}<span class="badge ok">включено</span> Кабинет и страница входа видны только адресам белого списка администраторов, остальным — «не найдено».{{else}}<span class="badge warn">выключено</span> Страница входа открывается с любого адреса.{{end}}</p>
{{if and $d.AdminAllowOnly (not $d.AdminOpen)}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Снять ограничение? Страница входа станет доступна с любого адреса.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="allow-only-off"><button class="btn" type="submit">Снять ограничение</button></form>
{{else}}<form method="post" action="{{adm}}/security/access" onsubmit="return confirm('Открывать кабинет только с адресов белого списка?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="allow-only-on"><button class="btn primary" type="submit">Включить ограничение</button></form>{{end}}
<p class="muted small" style="margin:8px 0 0">API активации и продления это не затрагивает. Ваш адрес <span class="mono">{{$d.MyIP}}</span> {{if $d.MyIPCovered}}в белом списке{{else}}добавится в белый список при включении{{end}}. Аварийно открыть кабинет по SSH: <span class="mono">/opt/bj-license/bj-license-server fw-apply -off</span></p>
</div>
<div>
<h3 style="margin:0 0 6px;font-size:15px">Второй фактор</h3>
<p style="margin:0 0 8px">{{if eq $d.TwoFA $d.Admins}}<span class="badge ok">у всех</span>{{else}}<span class="badge warn">{{$d.TwoFA}} из {{$d.Admins}}</span>{{end}} администраторов входят с кодом из приложения.</p>
<div class="actions"><a class="btn" href="{{adm}}/me/2fa">Мой второй фактор</a><a class="btn" href="{{adm}}/users">Администраторы</a></div>
<p class="muted small" style="margin:8px 0 0">Даже зная пароль, без телефона войти нельзя. Потерянный телефон: второй фактор сбрасывает другой администратор или по SSH <span class="mono">admin-config -reset-2fa логин</span> при остановленном сервере.</p>
</div>
</div></div>
<div class="card"><h2>ЗЕТИТ Ключ</h2>
<p style="margin:0 0 10px">{{if $d.ZKEnabled}}<span class="badge ok">включён</span> После пароля вход подтверждается push-уведомлением в приложении «ЗЕТИТ Ключ» или кодом из него. Действует для администраторов с указанным e-mail ({{$d.ZKWithEmail}} из {{$d.Admins}}).{{else}}<span class="badge warn">выключен</span> Второй фактор через приложение «ЗЕТИТ Ключ», как при входе в ZETIT Connect.{{end}}</p>
<form class="f" method="post" action="{{adm}}/security/zetit-key" style="max-width:620px"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Адрес API</label><input type="url" name="url" value="{{$d.ZKURL}}" placeholder="https://key.zetit.ru/api/v1/connect-auth"></div>
<div><label>Ключ приложения (X-Connect-Key)</label><input type="password" name="app_key" autocomplete="off" placeholder="{{if $d.ZKHasKey}}задан — оставьте пустым, чтобы не менять{{else}}панель ЗЕТИТ Ключ → Система → Внешние приложения → Добавить{{end}}"></div>
<label style="display:flex;gap:8px;align-items:center;font-weight:600"><input type="checkbox" name="enabled" value="1" {{if $d.ZKEnabled}}checked{{end}}> Требовать подтверждение в ЗЕТИТ Ключ при входе</label>
<div class="actions"><button class="btn primary" type="submit" name="op" value="save">Сохранить</button><button class="btn" type="submit" name="op" value="test">Проверить мой e-mail</button></div>
</form>
<p class="muted small" style="margin:10px 0 0">E-mail администраторов задаётся на странице <a href="{{adm}}/users">«Администраторы»</a> и должен совпадать с учётной записью в ЗЕТИТ Ключ. Включить можно, только если ваш e-mail найден в Ключе с привязанным приложением. У администраторов без e-mail действует пароль и код приложения-аутентификатора, если он подключён. Если Ключ недоступен, вход с e-mail невозможен — аварийно выключить по SSH при остановленном сервере: <span class="mono">bj-license-server admin-config -zetit-key off</span></p>
</div>
{{if not $d.StatusFresh}}<div class="card"><span class="badge bad">Помощник защиты не отвечает</span>
<p class="small" style="margin:8px 0 0">{{if $d.Status.At.IsZero}}Статуса ещё нет.{{else}}Последний отчёт {{dt $d.Status.At}} ({{ago $d.Status.At}}).{{end}} Команды из этого раздела не применяются. На сервере: <span class="mono">systemctl status bj-license-fw.timer bj-license-fw.path</span></p></div>{{end}}
<div class="card">
<h2>Режим обороны</h2>
<p>{{if $d.Status.Lockdown}}<span class="badge bad">Оборона включена</span> На сайт и API пускаются только адреса из белого списка ({{$d.Status.AllowCount}}){{if not $d.Status.Until.IsZero}}, автоматически выключится {{dt $d.Status.Until}}{{end}}.
{{else if $d.Status.Emergency}}<span class="badge warn">Оборона выключена аварийно по SSH</span> Включить снова можно кнопкой ниже.
{{else}}<span class="badge ok">Обычный режим</span> Доступ открыт всем, fail2ban блокирует адреса, которые перебирают пароли и коды.{{end}}</p>
{{if $d.WantedActive}}
<form method="post" action="{{adm}}/security/mode" class="actions" onsubmit="return confirm('Выключить режим обороны и открыть доступ всем?')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="off"><button class="btn primary" type="submit">Выключить оборону</button></form>
{{else}}
<form method="post" action="{{adm}}/security/mode" class="actions" onsubmit="return confirm('Включить режим обороны? Сайт и API станут недоступны всем, кроме белого списка.')"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="on">
<select name="hours"><option value="1">на 1 час</option><option value="6">на 6 часов</option><option value="24" selected>на 24 часа</option><option value="0">без срока</option></select>
<button class="btn danger" type="submit">Включить оборону</button></form>
{{end}}
<p class="muted small" style="margin:10px 0 0">Для атаки на сервер: все, кроме белого списка, получают отказ на уровне файрвола (порты 80 и 443). В белом списке — доверенные IP установок ({{$d.TrustedCount}}, <a href="{{adm}}/installs">Установки</a>) и адреса администраторов ниже. Ваш адрес <span class="mono">{{$d.MyIP}}</span> {{if $d.MyIPCovered}}уже в белом списке{{else}}добавится в белый список автоматически при включении{{end}}. SSH не затрагивается. Пока оборона включена, Let's Encrypt не сможет продлить сертификат. Аварийно выключить по SSH: <span class="mono">/opt/bj-license/bj-license-server fw-apply -off</span></p>
{{if $d.Status.Errors}}<div style="margin-top:10px">{{range $d.Status.Errors}}<div class="badge bad" style="display:block;margin-top:4px;white-space:normal">{{.}}</div>{{end}}</div>{{end}}
</div>
<div class="card"><h2>Белый список администраторов</h2>
<p class="muted small" style="margin:0 0 10px">Адреса офиса и администраторов. Они не блокируются fail2ban (ни за вход в кабинет, ни за API) и пропускаются в режиме обороны.</p>
<div class="table-wrap"><table>
<thead><tr><th>IP или сеть</th><th>Комментарий</th><th>Добавил</th><th></th></tr></thead><tbody>
{{range $d.Allow}}<tr><td class="mono"><b>{{.Net}}</b></td><td class="small">{{.Comment}}</td><td class="small">{{.AddedBy}}<div class="muted">{{dt .AddedAt}}</div></td>
<td><form method="post" action="{{adm}}/security/allow"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="remove"><input type="hidden" name="net" value="{{.Net}}"><button class="btn sm danger" type="submit">Убрать</button></form></td></tr>
{{else}}<tr><td colspan="4" class="muted">Пока пусто.</td></tr>{{end}}
</tbody></table></div>
<form method="post" action="{{adm}}/security/allow" class="actions" style="margin-top:10px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="add">
<input name="net" placeholder="IP или сеть, например 203.0.113.10 или 203.0.113.0/24" required style="max-width:340px"><input name="comment" placeholder="комментарий" style="max-width:260px"><button class="btn" type="submit">Добавить</button></form>
</div>
<div class="card"><h2>Параметры блокировок</h2>
<p class="muted small" style="margin:0 0 10px">Сколько неудачных попыток за период приводят к блокировке адреса и на какой срок. Долгие блокировки сохраняются и после перезапуска fail2ban. Белый список и доверенные установки не блокируются.</p>
<form method="post" action="{{adm}}/security/limits"><input type="hidden" name="csrf" value="{{$csrf}}">
<div class="table-wrap"><table>
<thead><tr><th>Что защищаем</th><th>Попыток</th><th>За период</th><th>Блокировать на</th></tr></thead><tbody>
{{range $d.Limits}}{{$row := .}}<tr><td><b>{{.Title}}</b>{{if .HasEffective}}<div class="muted small">{{.Effective}}</div>{{end}}</td>
<td><select name="retry_{{.Jail}}">{{range .RetryOpts}}<option value="{{.}}" {{if eq . $row.Conf.MaxRetry}}selected{{end}}>{{.}}</option>{{end}}</select></td>
<td><select name="find_{{.Jail}}">{{range .FindOpts}}<option value="{{.Sec}}" {{if eq .Sec $row.Conf.FindTime}}selected{{end}}>{{.Label}}</option>{{end}}</select></td>
<td><select name="ban_{{.Jail}}">{{range .BanOpts}}<option value="{{.Sec}}" {{if eq .Sec $row.Conf.BanTime}}selected{{end}}>{{.Label}}</option>{{end}}</select></td></tr>
{{end}}
<tr><td><b>Вход в кабинет (приложение)</b><div class="muted small">срабатывает раньше fail2ban: после N неудачных попыток форма входа отказывает этому адресу и логину</div></td>
<td><select name="login_tries">{{range $d.RetryPresets}}<option value="{{.}}" {{if eq . $d.LoginTries}}selected{{end}}>{{.}}</option>{{end}}{{if and (ne $d.LoginTries 3) (ne $d.LoginTries 5) (ne $d.LoginTries 10) (ne $d.LoginTries 20)}}<option value="{{$d.LoginTries}}" selected>{{$d.LoginTries}}</option>{{end}}</select></td>
<td class="muted small">за срок блокировки</td>
<td><select name="login_lock">{{range $d.BanPresets}}{{if ne .Sec -1}}<option value="{{.Sec}}" {{if eq .Sec $d.LoginLockSec}}selected{{end}}>{{.Label}}</option>{{end}}{{end}}</select></td></tr>
</tbody></table></div>
<div class="actions" style="margin-top:10px"><button class="btn primary" type="submit">Сохранить параметры</button></div>
</form>
</div>
<div class="card"><h2>fail2ban</h2>
<div class="table-wrap"><table>
<thead><tr><th>Jail</th><th>Ошибок сейчас / всего</th><th>Заблокировано сейчас / всего</th><th>Заблокированные адреса</th></tr></thead><tbody>
{{range $d.Status.Jails}}{{$jail := .Name}}<tr><td><b>{{.Name}}</b><div class="muted small">{{if eq .Name "bj-license-api"}}коды активации и токены{{else if eq .Name "bj-license-admin"}}вход в кабинет{{else if eq .Name "sshd"}}SSH{{end}}</div></td>
<td>{{.CurrentlyFailed}} / {{.TotalFailed}}</td><td>{{.CurrentlyBanned}} / {{.TotalBanned}}</td>
<td>{{range .Banned}}<form method="post" action="{{adm}}/security/ban" class="actions" style="margin:0 0 4px"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="hidden" name="op" value="unban"><input type="hidden" name="jail" value="{{$jail}}"><input type="hidden" name="ip" value="{{.}}"><span class="mono">{{.}}</span><button class="btn sm" type="submit">Разблокировать</button></form>{{else}}<span class="muted">нет</span>{{end}}</td></tr>
{{else}}<tr><td colspan="4" class="muted">Данных пока нет.</td></tr>{{end}}
</tbody></table></div>
{{if $d.Pending}}<p class="small" style="margin:10px 0 0"><b>Ожидают применения:</b> {{range $d.Pending}}<span class="mono">{{.Op}} {{.IP}} ({{.Jail}})</span> {{end}}</p>{{end}}
<form method="post" action="{{adm}}/security/ban" class="actions" style="margin-top:12px"><input type="hidden" name="csrf" value="{{$csrf}}">
<input name="ip" placeholder="IP-адрес" required style="max-width:220px">
<select name="jail">{{range $d.Jails}}<option value="{{.}}">{{.}}</option>{{end}}</select>
<select name="op"><option value="ban">Заблокировать</option><option value="unban">Разблокировать</option></select>
<button class="btn" type="submit">Выполнить</button></form>
<p class="muted small" style="margin:8px 0 0">Ручная блокировка действует срок блокировки jail из «Параметров блокировок». Статус обновляется раз в 30 секунд{{if not $d.Status.At.IsZero}}, последний — {{dt $d.Status.At}}{{end}}.</p>
</div>
{{template "foot" .}}{{end}}
{{define "totp"}}{{template "head" .}}{{$csrf := .CSRF}}{{$d := .Data}}
<h1>Второй фактор входа</h1>
<div class="card">
{{if $d.Enabled}}
<p><span class="badge ok">Подключён</span> При входе в кабинет, кроме пароля, нужен шестизначный код из приложения.</p>
<form class="f" method="post" action="{{adm}}/me/2fa/disable" style="max-width:360px"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Код из приложения, чтобы отключить</label><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required></div>
<div><button class="btn danger" type="submit">Отключить второй фактор</button></div></form>
{{else}}
<p style="margin-top:0">Установите на телефон приложение для одноразовых кодов: Яндекс Ключ, Google Authenticator, Microsoft Authenticator или любое с поддержкой TOTP.</p>
<div class="grid">
<div><h2>1. Добавьте ключ в приложение</h2>
{{if $d.QR}}<div style="background:#fff;display:inline-block;padding:8px;border-radius:8px">{{$d.QR}}</div>{{end}}
<p class="small">Отсканируйте QR-код или введите ключ вручную: <span class="mono"><b>{{$d.SecretGrouped}}</b></span>. Если открыли кабинет на телефоне — <a href="{{$d.URI}}">добавить в приложение</a>.</p></div>
<div><h2>2. Подтвердите кодом</h2>
<form class="f" method="post" action="{{adm}}/me/2fa/enable"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Шестизначный код из приложения</label><input name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required autofocus></div>
<div><button class="btn primary" type="submit">Подключить</button></div></form>
<p class="muted small">Ключ показывается только на этой странице. Если телефон потеряется, второй фактор сбросит другой администратор на странице «Администраторы».</p></div>
</div>
{{end}}
</div>
{{template "foot" .}}{{end}}
{{define "events"}}{{template "head" .}}
<h1>Журнал</h1>
<div class="card">{{template "evtable" .Data}}<p class="muted small" style="margin:10px 0 0">Последние 500 событий.</p></div>
{{template "foot" .}}{{end}}
{{define "users"}}{{template "head" .}}{{$csrf := .CSRF}}{{$me := .Me}}
<h1>Администраторы</h1>
<div class="card"><div class="table-wrap"><table>
<thead><tr><th>Логин</th><th>Имя</th><th>E-mail (ЗЕТИТ Ключ)</th><th>Последний вход</th><th>Статус</th><th>Второй фактор</th><th>Сменить пароль</th><th></th></tr></thead><tbody>
{{range .Data}}<tr>
<td><b>{{.Login}}</b></td><td>{{.Name}}</td><td><form method="post" action="{{adm}}/users/{{.Login}}/email" class="actions" style="margin:0"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="email" name="email" value="{{.Email}}" placeholder="e-mail в ЗЕТИТ Ключ" style="max-width:220px"><button class="btn sm" type="submit">Сохранить</button></form></td><td class="small">{{dt .LastLogin}}</td>
<td>{{if .Disabled}}<span class="badge bad">отключён</span>{{else}}<span class="badge ok">активен</span>{{end}}</td>
<td>{{if .HasTOTP}}<span class="badge ok">подключён</span>{{if ne .Login $me.Login}}<form method="post" action="{{adm}}/users/{{.Login}}/2fa-reset" style="margin:4px 0 0" onsubmit="return confirm('Сбросить второй фактор администратора?')"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm" type="submit">Сбросить</button></form>{{end}}{{else}}<span class="badge warn">нет</span>{{end}}{{if eq .Login $me.Login}}<div><a class="small" href="{{adm}}/me/2fa">{{if .HasTOTP}}управлять{{else}}подключить{{end}}</a></div>{{end}}</td>
<td><form class="actions" method="post" action="{{adm}}/users/{{.Login}}/password"><input type="hidden" name="csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="новый пароль, от 10 символов" minlength="10" required autocomplete="new-password" style="max-width:230px"><button class="btn sm" type="submit">Задать</button></form></td>
<td>{{if ne .Login $me.Login}}<form method="post" action="{{adm}}/users/{{.Login}}/toggle"><input type="hidden" name="csrf" value="{{$csrf}}"><button class="btn sm {{if not .Disabled}}danger{{end}}" type="submit">{{if .Disabled}}Включить{{else}}Отключить{{end}}</button></form>{{end}}</td>
</tr>{{end}}
</tbody></table></div></div>
<div class="grid">
<div class="card"><h2>Новый администратор</h2>
<form class="f" method="post" action="{{adm}}/users"><input type="hidden" name="csrf" value="{{$csrf}}">
<div class="row2"><div><label>Логин</label><input type="text" name="login" required pattern="[A-Za-z0-9_.\-]{2,40}"></div><div><label>Имя</label><input type="text" name="name"></div></div>
<div><label>E-mail в ЗЕТИТ Ключ <span class="muted" style="font-weight:400">(для подтверждения входа в приложении)</span></label><input type="email" name="email"></div>
<div><label>Пароль (от 10 символов)</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div>
<div><button class="btn primary" type="submit">Добавить</button></div>
</form></div>
<div class="card"><h2>Мой пароль</h2>
<form class="f" method="post" action="{{adm}}/me/password"><input type="hidden" name="csrf" value="{{$csrf}}">
<div><label>Текущий пароль</label><input type="password" name="current" required autocomplete="current-password"></div>
<div class="row2"><div><label>Новый пароль</label><input type="password" name="password" minlength="10" required autocomplete="new-password"></div><div><label>Повтор</label><input type="password" name="password2" minlength="10" required autocomplete="new-password"></div></div>
<div><button class="btn primary" type="submit">Сменить пароль</button></div>
</form></div>
</div>
{{template "foot" .}}{{end}}