cd70676555
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
485 lines
52 KiB
HTML
485 lines
52 KiB
HTML
{{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&Join</title>{{template "style"}}</head><body>
|
||
<header>
|
||
<a class="brand" href="{{adm}}/">{{template "logo"}}<span>Bridge<em>&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&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>&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&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>&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}}
|