feat(web): complete localized sync server console
This commit is contained in:
@@ -26,17 +26,22 @@ body { min-width:320px; margin:0; background:var(--bg); color:var(--text); font:
|
||||
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
|
||||
.stack { display:grid; gap:1rem; }
|
||||
.stack label { display:grid; gap:.35rem; font-weight:650; }
|
||||
.stack input,.inline-form input { width:100%; min-height:42px; padding:.55rem .65rem; border:1px solid var(--line); border-radius:8px; background:#0d151a; color:var(--text); font:inherit; }
|
||||
.stack input:not([type="checkbox"]),.stack select,.inline-form input { width:100%; min-height:42px; padding:.55rem .65rem; border:1px solid var(--line); border-radius:8px; background:#0d151a; color:var(--text); font:inherit; }
|
||||
.check-field { display:flex !important; align-items:center; gap:.55rem; }.check-field input[type="checkbox"] { width:1.1rem; height:1.1rem; margin:0; accent-color:var(--accent); }
|
||||
.flash { padding:.75rem; border-radius:8px; }
|
||||
.flash.error { border:1px solid #6e343b; background:#3a2025; color:#ffd9d9; }
|
||||
.flash.success { border:1px solid #286052; background:#173d36; color:#a5f1df; }
|
||||
.one-time-secret { display:block; overflow-wrap:anywhere; padding:.8rem; border:1px solid var(--accent); border-radius:.55rem; background:#0c171d; color:var(--accent); font-size:1.1rem; }
|
||||
.warning { border-color:#6b5b28; background:#302a16; color:#ffedb0; }
|
||||
.dashboard-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin-bottom:1.25rem; }
|
||||
.table-card { padding:1.25rem; }
|
||||
.table-scroll { overflow-x:auto; }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
table { width:100%; min-width:720px; border-collapse:collapse; }
|
||||
th,td { padding:.75rem; border-bottom:1px solid var(--line); text-align:left; vertical-align:middle; }
|
||||
th { color:var(--muted); font-size:.8rem; letter-spacing:.06em; text-transform:uppercase; }
|
||||
.badge { display:inline-flex; padding:.2rem .55rem; border-radius:999px; font-size:.8rem; font-weight:700; }
|
||||
.badge.ok { background:#173d36; color:#91ecd9; }.badge.danger { background:#4c2529; color:#ffd9d9; }
|
||||
.badge.warning { background:#4b421d; color:#ffed9a; }
|
||||
.inline-form { display:flex; min-width:250px; gap:.4rem; }.inline-form input { min-width:120px; }
|
||||
.link-button { padding:0; border:0; background:transparent; color:var(--muted); font:inherit; cursor:pointer; }
|
||||
.admin-shell { display:grid; grid-template-columns:220px minmax(0,1fr); gap:1.5rem; }
|
||||
@@ -49,4 +54,5 @@ details { margin-top:.6rem; } summary { cursor:pointer; color:var(--accent); }.c
|
||||
.list-filter,.pager { display:flex; flex-wrap:wrap; align-items:end; gap:.65rem; margin:0 0 1rem; }.list-filter label { display:grid; gap:.3rem; color:var(--muted); }.list-filter input,.list-filter select { min-height:38px; padding:.4rem .55rem; border:1px solid var(--line); border-radius:7px; background:#0d151a; color:var(--text); }.pager { justify-content:flex-end; align-items:center; }
|
||||
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }.muted,.empty { color:var(--muted); }.empty { padding:2rem; text-align:center; }
|
||||
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.confirm-dialog { width:min(calc(100% - 2rem),420px); margin:auto; padding:1.25rem; border:1px solid var(--line); border-radius:14px; background:var(--surface); color:var(--text); box-shadow:0 24px 64px #0009; }.confirm-dialog::backdrop { background:#05080bb8; }.confirm-dialog h2,.confirm-dialog p { margin:0; }
|
||||
@media (max-width:760px) { .site-header,.site-footer,.dashboard-head,.section-heading { align-items:flex-start; flex-direction:column; }.locale-form { margin-left:0; }.page-shell { width:min(100% - 1.25rem,1100px); padding:2rem 0; }.hero { padding:1.5rem; }.inline-form { min-width:0; flex-wrap:wrap; }.admin-shell { grid-template-columns:1fr; }.admin-nav { grid-template-columns:repeat(2,minmax(0,1fr)); }.admin-nav .eyebrow { grid-column:1 / -1; } }
|
||||
|
||||
@@ -1,7 +1,38 @@
|
||||
document.addEventListener("change", function (event) {
|
||||
if (event.target.matches("[data-auto-submit]")) event.target.form.requestSubmit();
|
||||
});
|
||||
|
||||
const confirmationDialog = document.getElementById("confirm-dialog");
|
||||
let confirmationForm = null;
|
||||
let confirmationButton = null;
|
||||
document.addEventListener("click", function (event) {
|
||||
const button = event.target.closest("[data-confirm]");
|
||||
if (button && !window.confirm(button.dataset.confirm)) event.preventDefault();
|
||||
if (!button || !button.form || !confirmationDialog) return;
|
||||
event.preventDefault();
|
||||
confirmationForm = button.form;
|
||||
confirmationButton = button;
|
||||
document.getElementById("confirm-dialog-message").textContent = button.dataset.confirm;
|
||||
confirmationDialog.showModal();
|
||||
});
|
||||
if (confirmationDialog) {
|
||||
confirmationDialog.addEventListener("close", function () {
|
||||
if (confirmationDialog.returnValue === "confirm" && confirmationForm) {
|
||||
confirmationForm.requestSubmit(confirmationButton);
|
||||
}
|
||||
confirmationForm = null;
|
||||
confirmationButton = null;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", async function (event) {
|
||||
const button = event.target.closest("[data-copy-diagnostics]");
|
||||
if (!button || !navigator.clipboard) return;
|
||||
try {
|
||||
const response = await fetch(button.dataset.copyDiagnostics, { credentials: "same-origin" });
|
||||
if (!response.ok) return;
|
||||
await navigator.clipboard.writeText(await response.text());
|
||||
button.textContent = button.dataset.copiedLabel;
|
||||
} catch (_) {
|
||||
// The downloadable JSON link remains available when clipboard access is unavailable.
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{{define "admin"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">
|
||||
<aside class="admin-nav" aria-label="{{t .Locale "admin.navigation"}}">
|
||||
<p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p>
|
||||
<a href="/admin/dashboard" {{if eq .AdminPage "dashboard"}}aria-current="page"{{end}}>{{t .Locale "admin.overview"}}</a>
|
||||
<a href="/admin/users" {{if eq .AdminPage "users"}}aria-current="page"{{end}}>{{t .Locale "admin.users"}}</a>
|
||||
<a href="/admin/devices" {{if eq .AdminPage "devices"}}aria-current="page"{{end}}>{{t .Locale "admin.devices"}}</a>
|
||||
<a href="/admin/vaults" {{if eq .AdminPage "vaults"}}aria-current="page"{{end}}>{{t .Locale "admin.vaults"}}</a>
|
||||
<a href="/admin/storage" {{if eq .AdminPage "storage"}}aria-current="page"{{end}}>{{t .Locale "admin.storage"}}</a>
|
||||
<a href="/admin/audit" {{if eq .AdminPage "audit"}}aria-current="page"{{end}}>{{t .Locale "admin.audit"}}</a>
|
||||
<a href="/admin/settings" {{if eq .AdminPage "settings"}}aria-current="page"{{end}}>{{t .Locale "admin.settings"}}</a>
|
||||
<a href="/admin/diagnostics" {{if eq .AdminPage "diagnostics"}}aria-current="page"{{end}}>{{t .Locale "admin.diagnostics"}}</a>
|
||||
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link-button" type="submit">{{t .Locale "auth.logout"}}</button></form>
|
||||
</aside>
|
||||
<div class="admin-content">
|
||||
{{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="blocked" {{if eq .List.Status "blocked"}}selected{{end}}>{{t .Locale "admin.blocked"}}</option><option value="revoked" {{if eq .List.Status "revoked"}}selected{{end}}>{{t .Locale "device.revoked"}}</option><option value="unconfirmed" {{if eq .List.Status "unconfirmed"}}selected{{end}}>{{t .Locale "admin.unconfirmed"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>{{end}}
|
||||
{{if eq .AdminPage "dashboard"}}<p class="eyebrow">{{t .Locale "admin.overview"}}</p><h1>{{t .Locale "admin.dashboard"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.Users}}</strong><span>{{t .Locale "admin.users"}}</span></article><article class="card stat"><strong>{{.Stats.ActiveDevices}}</strong><span>{{t .Locale "admin.activeDevices"}}</span></article><article class="card stat"><strong>{{.Stats.Vaults}}</strong><span>{{t .Locale "admin.vaults"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><section class="card panel"><h2>{{t .Locale "admin.serviceHealth"}}</h2><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{.Health.Status}}</dd><dt>{{t .Locale "admin.version"}}</dt><dd>{{.Health.Version}} {{.Health.BuildCommit}}</dd><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{.Stats.LastSyncAt}}</dd></dl></section>
|
||||
{{else if eq .AdminPage "users"}}<div class="section-heading"><div><p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.users"}}</h1></div><a class="button primary" href="/admin/create-user">{{t .Locale "admin.createUser"}}</a></div><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "field.username"}}</th><th>{{t .Locale "field.email"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminUsers}}<tr><td>{{.Username}}</td><td>{{.Email}}</td><td>{{.Devices}}</td><td>{{if .Blocked}}<span class="badge danger">{{t $.Locale "admin.blocked"}}</span>{{else if .Confirmed}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{else}}<span class="badge">{{t $.Locale "admin.unconfirmed"}}</span>{{end}}</td><td><form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="toggle-user"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" required placeholder="{{t $.Locale "field.password"}}"><button class="button secondary" type="submit">{{if .Blocked}}{{t $.Locale "admin.unblock"}}{{else}}{{t $.Locale "admin.block"}}{{end}}</button></form><details><summary>{{t $.Locale "admin.manage"}}</summary><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="edit-user"><label>{{t $.Locale "field.username"}}<input name="username" value="{{.Username}}" required></label><label>{{t $.Locale "field.email"}}<input name="email" type="email" value="{{.Email}}" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.saveUser"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="reset-user-password"><label>{{t $.Locale "field.newPassword"}}<input name="new_password" type="password" required></label><label>{{t $.Locale "field.password"}}<input name="password" type="password" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.resetPassword"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="delete-user"><label>{{t $.Locale "field.password"}}<input name="password" type="password" required></label><button class="button danger" type="submit">{{t $.Locale "admin.deleteUser"}}</button></form></details></td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noUsers"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "devices"}}<p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.devices"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminDevices}}<tr><td>{{.Name}}</td><td>{{.User}}</td><td class="mono">{{short .Vault 16}}</td><td>{{.LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="revoke-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{else}}<tr><td colspan="6" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "vaults"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.vaults"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.operations"}}</th><th>{{t .Locale "admin.lastActivity"}}</th></tr></thead><tbody>{{range .Vaults}}<tr><td>{{.User}}</td><td class="mono"><a href="/admin/vault/?user={{.UserID}}&vault={{.Vault}}">{{short .Vault 24}}</a></td><td>{{.Devices}}</td><td>{{.Operations}}</td><td>{{.LastActivity}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noVaults"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "storage"}}<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.storage"}}</h1><div class="stat-grid"><article class="card stat"><strong>{{.Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{.Stats.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div><p class="muted">{{t .Locale "admin.retentionNote"}}</p><form method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="cleanup"><input name="password" type="password" required placeholder="{{t .Locale "field.password"}}"><button class="button secondary" type="submit">{{t .Locale "admin.runCleanup"}}</button></form>
|
||||
{{else if eq .AdminPage "audit"}}<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.audit"}}</h1><section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.time"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{.Event}}</td><td>{{.User}}</td><td>{{.Device}}</td><td>{{.At}}</td></tr>{{else}}<tr><td colspan="4" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
{{else if eq .AdminPage "settings"}}<p class="eyebrow">{{t .Locale "admin.settings"}}</p><h1>{{t .Locale "admin.smtpTitle"}}</h1><section class="card panel"><form method="post" action="/admin/action" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp"><label>{{t .Locale "admin.smtpServer"}}<input name="smtp_host" value="{{.SMTP.Host}}"></label><label>{{t .Locale "admin.smtpPort"}}<input name="smtp_port" value="{{.SMTP.Port}}"></label><label>{{t .Locale "admin.smtpUsername"}}<input name="smtp_user" value="{{.SMTP.User}}"></label><label>{{t .Locale "admin.smtpPassword"}}<input name="smtp_pass" type="password" autocomplete="new-password"></label><label>{{t .Locale "admin.smtpType"}}<select name="smtp_security"><option value="none" {{if eq .SMTP.Security "none"}}selected{{end}}>{{t .Locale "admin.smtpNoEncryption"}}</option><option value="starttls" {{if eq .SMTP.Security "starttls"}}selected{{end}}>STARTTLS</option><option value="tls" {{if eq .SMTP.Security "tls"}}selected{{end}}>TLS</option></select></label><label>{{t .Locale "admin.smtpFrom"}}<input name="smtp_from" value="{{.SMTP.From}}"></label><label>{{t .Locale "admin.smtpServerURL"}}<input name="server_url" value="{{.SMTP.ServerURL}}"></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "admin.smtpSave"}}</button></form></section>
|
||||
{{else if eq .AdminPage "diagnostics"}}<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.diagnostics"}}</h1><section class="card panel"><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{.Health.Status}}</dd><dt>{{t .Locale "admin.database"}}</dt><dd>{{.Health.DatabaseReachable}}</dd><dt>{{t .Locale "admin.blobStorage"}}</dt><dd>{{.Health.BlobStorageWritable}}</dd><dt>{{t .Locale "admin.schema"}}</dt><dd>{{.Health.SchemaVersion}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{.Health.ServerTime}}</dd></dl><div class="actions"><a class="button secondary" href="/api/v1/health">{{t .Locale "admin.healthJSON"}}</a><a class="button secondary" href="/admin/diagnostics.json">{{t .Locale "admin.downloadDiagnostics"}}</a></div></section>{{end}}
|
||||
{{if or (eq .AdminPage "users") (or (eq .AdminPage "devices") (eq .AdminPage "audit"))}}<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?q={{.List.Query}}&status={{.List.Status}}&page={{.List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?q={{.List.Query}}&status={{.List.Status}}&page={{.List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "admin_audit"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.audit"}}</h1>
|
||||
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "admin.event"}}<input name="event" value="{{.List.Event}}" maxlength="160"></label><label>{{t .Locale "admin.user"}}<input name="user" value="{{.List.User}}" maxlength="160"></label><label>{{t .Locale "admin.severity"}}<select name="severity"><option value="">{{t .Locale "admin.all"}}</option><option value="info" {{if eq .List.Severity "info"}}selected{{end}}>{{t .Locale "admin.info"}}</option><option value="warning" {{if eq .List.Severity "warning"}}selected{{end}}>{{t .Locale "admin.warningLevel"}}</option><option value="error" {{if eq .List.Severity "error"}}selected{{end}}>{{t .Locale "admin.errorLevel"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
|
||||
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.time"}}</th><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "admin.severity"}}</th><th>{{t .Locale "admin.message"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{webtime $.Locale .At}}</td><td>{{auditlabel $.Locale .Event}}</td><td>{{.User}}</td><td>{{.Device}}</td><td>{{.IP}}</td><td><span class="badge {{if eq .Severity "error"}}danger{{else if eq .Severity "warning"}}warning{{else}}ok{{end}}">{{if eq .Severity "error"}}{{t $.Locale "admin.errorLevel"}}{{else if eq .Severity "warning"}}{{t $.Locale "admin.warningLevel"}}{{else}}{{t $.Locale "admin.info"}}{{end}}</span></td><td>{{.Message}}</td></tr>{{else}}<tr><td colspan="7" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
|
||||
</div></section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "admin_dashboard"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.overview"}}</p><h1>{{t .Locale "admin.dashboard"}}</h1>
|
||||
<div class="stat-grid"><article class="card stat"><strong>{{.Stats.Users}}</strong><span>{{t .Locale "admin.users"}} · {{.Stats.ActiveUsers}} {{t .Locale "device.active"}}</span></article><article class="card stat"><strong>{{.Stats.ActiveDevices}}</strong><span>{{t .Locale "admin.activeDevices"}} · {{.Stats.RevokedDevices}} {{t .Locale "device.revoked"}}</span></article><article class="card stat"><strong>{{.Stats.Vaults}}</strong><span>{{t .Locale "admin.vaults"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}} · {{.Stats.Operations24h}} {{t .Locale "admin.ops24h"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.BlobBytes}}</strong><span>{{.Stats.Blobs}} {{t .Locale "admin.blobBytes"}}</span></article></div>
|
||||
{{if .Warnings}}<section class="card panel warning"><h2>{{t .Locale "common.warning"}}</h2>{{range .Warnings}}<p>{{t $.Locale .}}</p>{{end}}</section>{{end}}
|
||||
<section class="card panel"><h2>{{t .Locale "admin.serviceHealth"}}</h2><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{statuslabel .Locale .Health.Status}}</dd><dt>{{t .Locale "admin.version"}}</dt><dd>{{.Health.Version}} {{.Health.BuildCommit}}</dd><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{webtime .Locale .Stats.LastSyncAt}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{webtime .Locale .Health.ServerTime}}</dd></dl></section>
|
||||
<section class="card table-card"><h2>{{t .Locale "admin.audit"}}</h2><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.event"}}</th><th>{{t .Locale "admin.time"}}</th></tr></thead><tbody>{{range .Audit}}<tr><td>{{auditlabel $.Locale .Event}}</td><td>{{webtime $.Locale .At}}</td></tr>{{else}}<tr><td colspan="2" class="empty">{{t .Locale "admin.noAudit"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
</div></section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "admin_devices"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.devices"}}</h1>
|
||||
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160" placeholder="{{t .Locale "admin.search"}}"></label><label>{{t .Locale "admin.user"}}<input name="user" value="{{.List.User}}" maxlength="160"></label><label>{{t .Locale "device.vault"}}<input name="vault" value="{{.List.Vault}}" maxlength="160"></label><label>{{t .Locale "device.version"}}<input name="version" value="{{.List.Version}}" maxlength="160"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="revoked" {{if eq .List.Status "revoked"}}selected{{end}}>{{t .Locale "device.revoked"}}</option></select></label><label>{{t .Locale "admin.sort"}}<select name="sort"><option value="created" {{if eq .List.Sort "created"}}selected{{end}}>{{t .Locale "admin.created"}}</option><option value="name" {{if eq .List.Sort "name"}}selected{{end}}>{{t .Locale "device.name"}}</option><option value="last_seen" {{if eq .List.Sort "last_seen"}}selected{{end}}>{{t .Locale "admin.lastSeen"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
|
||||
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "admin.created"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminDevices}}<tr><td>{{.Name}}{{if .TokenHint}}<small class="muted">{{.TokenHint}}</small>{{end}}</td><td>{{.User}}</td><td class="mono">{{short .Vault 16}}</td><td>{{.Version}}</td><td>{{.LastIP}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="revoke-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{else}}<form class="inline-form" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="delete-device"><input type="hidden" name="id" value="{{.ID}}"><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{t $.Locale "admin.deleteDevice"}}</button></form>{{end}}</td></tr>{{else}}<tr><td colspan="9" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
|
||||
</div></section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "admin_diagnostics"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.diagnostics"}}</p><h1>{{t .Locale "admin.diagnostics"}}</h1>
|
||||
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.status"}}</dt><dd>{{statuslabel .Locale .Health.Status}}</dd><dt>{{t .Locale "admin.database"}}</dt><dd>{{boollabel .Locale .Health.DatabaseReachable}}</dd><dt>{{t .Locale "admin.blobStorage"}}</dt><dd>{{boollabel .Locale .Health.BlobStorageWritable}}</dd><dt>{{t .Locale "admin.schema"}}</dt><dd>{{.Health.SchemaVersion}}</dd><dt>{{t .Locale "admin.serverTime"}}</dt><dd>{{webtime .Locale .Health.ServerTime}}</dd></dl><div class="actions"><a class="button secondary" href="/admin/diagnostics">{{t .Locale "admin.refresh"}}</a><button class="button secondary" type="button" data-copy-diagnostics="/admin/diagnostics.json" data-copy-label="{{t .Locale "admin.copyDiagnostics"}}" data-copied-label="{{t .Locale "admin.copied"}}">{{t .Locale "admin.copyDiagnostics"}}</button><a class="button secondary" href="/api/v1/health">{{t .Locale "admin.healthJSON"}}</a><a class="button secondary" href="/admin/diagnostics.json">{{t .Locale "admin.downloadDiagnostics"}}</a></div></section>
|
||||
</div></section>
|
||||
{{end}}
|
||||
@@ -1,2 +1,2 @@
|
||||
{{define "admin_login"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p><h1>{{t .Locale "admin.loginTitle"}}</h1>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<form method="post" action="/admin/login" class="stack"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form></section>{{end}}
|
||||
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p><h1>{{t .Locale "admin.loginTitle"}}</h1>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<form method="post" action="/admin/login" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form></section>{{end}}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{{define "admin_nav"}}
|
||||
<aside class="admin-nav" aria-label="{{t .Locale "admin.navigation"}}">
|
||||
<p class="eyebrow">{{t .Locale "admin.eyebrow"}}</p>
|
||||
<a href="/admin/dashboard" {{if eq .AdminPage "dashboard"}}aria-current="page"{{end}}>{{t .Locale "admin.overview"}}</a>
|
||||
<a href="/admin/users" {{if eq .AdminPage "users"}}aria-current="page"{{end}}>{{t .Locale "admin.users"}}</a>
|
||||
<a href="/admin/devices" {{if eq .AdminPage "devices"}}aria-current="page"{{end}}>{{t .Locale "admin.devices"}}</a>
|
||||
<a href="/admin/vaults" {{if eq .AdminPage "vaults"}}aria-current="page"{{end}}>{{t .Locale "admin.vaults"}}</a>
|
||||
<a href="/admin/storage" {{if eq .AdminPage "storage"}}aria-current="page"{{end}}>{{t .Locale "admin.storage"}}</a>
|
||||
<a href="/admin/audit" {{if eq .AdminPage "audit"}}aria-current="page"{{end}}>{{t .Locale "admin.audit"}}</a>
|
||||
<a href="/admin/settings" {{if eq .AdminPage "settings"}}aria-current="page"{{end}}>{{t .Locale "admin.settings"}}</a>
|
||||
<a href="/admin/diagnostics" {{if eq .AdminPage "diagnostics"}}aria-current="page"{{end}}>{{t .Locale "admin.diagnostics"}}</a>
|
||||
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="link-button" type="submit">{{t .Locale "auth.logout"}}</button></form>
|
||||
</aside>
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "admin_password_result"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content narrow-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.resultTitle"}}</p><h1>{{t .Locale "admin.resetPassword"}}</h1>
|
||||
<section class="card panel warning"><p>{{t .Locale "admin.oneTimePasswordNotice"}}</p><code class="one-time-secret">{{.OneTimeSecret}}</code><p>{{t .Locale "admin.oneTimePasswordHint"}}</p></section>
|
||||
<a class="button secondary" href="/admin/users">{{t .Locale "admin.users"}}</a>
|
||||
</div></section>
|
||||
{{end}}
|
||||
@@ -1,2 +1,47 @@
|
||||
{{define "admin_settings"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="admin-shell"><div class="admin-content"><p class="eyebrow">{{t .Locale "admin.settings"}}</p><h1>{{t .Locale "admin.settings"}}</h1><section class="card panel"><h2>{{t .Locale "admin.general"}}</h2><form method="post" action="/admin/action" class="stack"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="web-settings"><label>{{t .Locale "admin.serverName"}}<input name="server_name" value="{{.ServerName}}"></label><label>{{t .Locale "locale.label"}}<select name="default_locale"><option value="en" {{if eq .DefaultLocale "en"}}selected{{end}}>English</option><option value="ru" {{if eq .DefaultLocale "ru"}}selected{{end}}>Русский</option></select></label><label><input name="allow_registration" type="checkbox" {{if .AllowRegistration}}checked{{end}}>{{t .Locale "admin.allowRegistration"}}</label><label>{{t .Locale "field.password"}}<input name="password" type="password" required></label><button class="button primary">{{t .Locale "admin.saveUser"}}</button></form></section><section class="card panel"><h2>{{t .Locale "admin.smtpTitle"}}</h2><p class="muted">{{t .Locale "admin.smtpConfigured"}}</p><a class="button secondary" href="/admin/dashboard">{{t .Locale "common.back"}}</a></section></div></section>{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">
|
||||
{{template "admin_nav" .}}
|
||||
<div class="admin-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.settings"}}</p>
|
||||
<h1>{{t .Locale "admin.settings"}}</h1>
|
||||
{{if .Flash}}<p class="flash success" role="status">{{t .Locale .Flash}}</p>{{end}}
|
||||
<section class="card panel">
|
||||
<h2>{{t .Locale "admin.general"}}</h2>
|
||||
<form method="post" action="/admin/action" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="web-settings">
|
||||
<label>{{t .Locale "admin.serverName"}}<input name="server_name" value="{{.ServerName}}"></label>
|
||||
<label>{{t .Locale "admin.publicURL"}}<input name="public_url" type="url" value="{{.PublicURL}}" placeholder="https://sync.example.test"></label>
|
||||
<label>{{t .Locale "locale.label"}}<select name="default_locale"><option value="en" {{if eq .DefaultLocale "en"}}selected{{end}}>English</option><option value="ru" {{if eq .DefaultLocale "ru"}}selected{{end}}>Русский</option></select></label>
|
||||
<label class="check-field"><input name="allow_registration" type="checkbox" {{if .AllowRegistration}}checked{{end}}>{{t .Locale "admin.allowRegistration"}}</label>
|
||||
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="button primary">{{t .Locale "admin.saveSettings"}}</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card panel"><h2>{{t .Locale "admin.network"}}</h2><dl class="details"><dt>{{t .Locale "admin.trustedProxies"}}</dt><dd>{{if .TrustedProxies}}{{.TrustedProxies}}{{else}}—{{end}}</dd></dl></section>
|
||||
<section class="card panel">
|
||||
<h2>{{t .Locale "admin.smtpTitle"}}</h2>
|
||||
<p class="muted">{{t .Locale "admin.smtpConfigured"}}</p>
|
||||
<form method="post" action="/admin/action" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp">
|
||||
<label>{{t .Locale "admin.smtpServer"}}<input name="smtp_host" value="{{.SMTP.Host}}" autocomplete="off"></label>
|
||||
<label>{{t .Locale "admin.smtpPort"}}<input name="smtp_port" inputmode="numeric" value="{{.SMTP.Port}}"></label>
|
||||
<label>{{t .Locale "admin.smtpUsername"}}<input name="smtp_user" value="{{.SMTP.User}}" autocomplete="username"></label>
|
||||
<label>{{t .Locale "admin.smtpPassword"}}<input name="smtp_pass" type="password" autocomplete="new-password"></label>
|
||||
<label>{{t .Locale "admin.smtpType"}}<select name="smtp_security"><option value="none" {{if eq .SMTP.Security "none"}}selected{{end}}>{{t .Locale "admin.smtpNoEncryption"}}</option><option value="starttls" {{if eq .SMTP.Security "starttls"}}selected{{end}}>{{t .Locale "admin.smtpStartTLS"}}</option><option value="tls" {{if eq .SMTP.Security "tls"}}selected{{end}}>{{t .Locale "admin.smtpTLS"}}</option></select></label>
|
||||
<label>{{t .Locale "admin.smtpFrom"}}<input name="smtp_from" type="email" value="{{.SMTP.From}}"></label>
|
||||
<label>{{t .Locale "admin.smtpServerURL"}}<input name="server_url" type="url" value="{{.SMTP.ServerURL}}"></label>
|
||||
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="button primary" type="submit">{{t .Locale "admin.smtpSave"}}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/action" class="inline-form top-gap">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="smtp-test">
|
||||
<label>{{t .Locale "field.email"}}<input name="test_to" type="email" placeholder="{{.SMTP.From}}"></label>
|
||||
<label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="button secondary" type="submit">{{t .Locale "admin.smtpTest"}}</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card panel"><h2>{{t .Locale "admin.limits"}}</h2><p class="muted">{{t .Locale "admin.readOnlyConfig"}}</p><dl class="details"><dt>{{t .Locale "admin.maxJSONBody"}}</dt><dd>{{webbytes .Limits.MaxJSONBody}}</dd><dt>{{t .Locale "admin.maxPushOperations"}}</dt><dd>{{.Limits.MaxPushOperations}}</dd><dt>{{t .Locale "admin.maxPullPage"}}</dt><dd>{{.Limits.MaxPullPage}}</dd><dt>{{t .Locale "admin.maxBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxBlobBytes}}</dd><dt>{{t .Locale "admin.maxVaultBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxVaultBlobBytes}}</dd><dt>{{t .Locale "admin.maxUserBlobBytes"}}</dt><dd>{{webbytes .Limits.MaxUserBlobBytes}}</dd></dl></section>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{{define "admin_storage"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.storage"}}</h1>
|
||||
{{if .Flash}}<p class="flash success" role="status">{{t .Locale .Flash}}</p>{{end}}
|
||||
<div class="stat-grid"><article class="card stat"><strong>{{webbytes .Stats.DatabaseBytes}}</strong><span>{{t .Locale "admin.databaseBytes"}}</span></article><article class="card stat"><strong>{{webbytes .Stats.BlobBytes}}</strong><span>{{.Stats.Blobs}} {{t .Locale "admin.blobBytes"}}</span></article><article class="card stat"><strong>{{.Stats.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article></div>
|
||||
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.blobReferences"}}</dt><dd>{{.Stats.BlobReferences}}</dd><dt>{{t .Locale "admin.orphanBlobs"}}</dt><dd>{{.Stats.OrphanBlobs}}</dd><dt>{{t .Locale "admin.tempUploads"}}</dt><dd>{{.Stats.TempUploads}}</dd><dt>{{t .Locale "admin.expiredSessions"}}</dt><dd>{{.Stats.ExpiredSessions}}</dd><dt>{{t .Locale "admin.expiredTokens"}}</dt><dd>{{.Stats.ExpiredTokens}}</dd><dt>{{t .Locale "admin.lastCleanup"}}</dt><dd>{{webtime .Locale .Stats.LastCleanupAt}}</dd></dl></section>
|
||||
<p class="muted">{{t .Locale "admin.retentionNote"}}</p><form method="post" action="/admin/action" class="inline-form"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><input type="hidden" name="action" value="cleanup"><label class="sr-only">{{t .Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t .Locale "field.password"}}"><button class="button secondary" type="submit" data-confirm="{{t .Locale "admin.revokeConfirm"}}">{{t .Locale "admin.runCleanup"}}</button></form>
|
||||
</div></section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{define "admin_users"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
|
||||
<div class="section-heading"><div><p class="eyebrow">{{t .Locale "admin.access"}}</p><h1>{{t .Locale "admin.users"}}</h1></div><a class="button primary" href="/admin/create-user">{{t .Locale "admin.createUser"}}</a></div>
|
||||
<form class="list-filter" method="get" action="{{.CurrentPath}}"><label>{{t .Locale "admin.search"}}<input name="q" value="{{.List.Query}}" maxlength="160"></label><label>{{t .Locale "device.status"}}<select name="status"><option value="">{{t .Locale "admin.all"}}</option><option value="active" {{if eq .List.Status "active"}}selected{{end}}>{{t .Locale "device.active"}}</option><option value="blocked" {{if eq .List.Status "blocked"}}selected{{end}}>{{t .Locale "admin.blocked"}}</option><option value="unconfirmed" {{if eq .List.Status "unconfirmed"}}selected{{end}}>{{t .Locale "admin.unconfirmed"}}</option></select></label><label>{{t .Locale "admin.sort"}}<select name="sort"><option value="created" {{if eq .List.Sort "created"}}selected{{end}}>{{t .Locale "admin.created"}}</option><option value="username" {{if eq .List.Sort "username"}}selected{{end}}>{{t .Locale "field.username"}}</option><option value="last_seen" {{if eq .List.Sort "last_seen"}}selected{{end}}>{{t .Locale "admin.lastSeen"}}</option></select></label><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form>
|
||||
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "field.username"}}</th><th>{{t .Locale "field.email"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.vaults"}}</th><th>{{t .Locale "admin.lastSeen"}}</th><th>{{t .Locale "admin.created"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .AdminUsers}}<tr><td>{{.Username}}</td><td>{{.Email}}</td><td>{{.Devices}}</td><td>{{.Vaults}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{if .Blocked}}<span class="badge danger">{{t $.Locale "admin.blocked"}}</span>{{else if .Confirmed}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{else}}<span class="badge">{{t $.Locale "admin.unconfirmed"}}</span>{{end}}</td><td><details><summary>{{t $.Locale "admin.manage"}}</summary>{{if not .Confirmed}}<form class="stack compact" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="confirm-user"><input type="hidden" name="id" value="{{.ID}}"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.confirmUser"}}</button></form>{{end}}<form class="stack compact" method="post" action="/admin/action"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="action" value="toggle-user"><input type="hidden" name="id" value="{{.ID}}"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit" data-confirm="{{t $.Locale "admin.revokeConfirm"}}">{{if .Blocked}}{{t $.Locale "admin.unblock"}}{{else}}{{t $.Locale "admin.block"}}{{end}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="edit-user"><label>{{t $.Locale "field.username"}}<input name="username" value="{{.Username}}" required></label><label>{{t $.Locale "field.email"}}<input name="email" type="email" value="{{.Email}}" required></label><button class="button secondary" type="submit">{{t $.Locale "admin.saveUser"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="reset-user-password"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button secondary" type="submit" data-confirm="{{t $.Locale "admin.resetPasswordConfirm"}}">{{t $.Locale "admin.generatePassword"}}</button></form><form method="post" action="/admin/action" class="stack compact"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><input type="hidden" name="id" value="{{.ID}}"><input type="hidden" name="action" value="delete-user"><label>{{t $.Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button danger" type="submit" data-confirm="{{t $.Locale "admin.deleteUserConfirm"}}">{{t $.Locale "admin.deleteUser"}}</button></form></details></td></tr>{{else}}<tr><td colspan="8" class="empty">{{t .Locale "admin.noUsers"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
<nav class="pager" aria-label="{{t .Locale "admin.pagination"}}"><span>{{.List.Total}}</span>{{if .List.Previous}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Previous}}">{{t .Locale "admin.previous"}}</a>{{end}}<span>{{.List.Page}} / {{.List.Pages}}</span>{{if .List.Next}}<a class="button secondary" href="{{.CurrentPath}}?{{listparams .List .List.Next}}">{{t .Locale "admin.next"}}</a>{{end}}</nav>
|
||||
</div></section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "admin_vaults"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.storage"}}</p><h1>{{t .Locale "admin.vaults"}}</h1>
|
||||
<section class="card table-card"><div class="table-scroll"><table><thead><tr><th>{{t .Locale "admin.user"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "admin.devices"}}</th><th>{{t .Locale "admin.operations"}}</th><th>{{t .Locale "admin.lastActivity"}}</th></tr></thead><tbody>{{range .Vaults}}<tr><td>{{.User}}</td><td class="mono"><a href="/admin/vault/?user={{.UserID}}&vault={{.Vault}}">{{short .Vault 24}}</a></td><td>{{.Devices}}</td><td>{{.Operations}}</td><td>{{.LastActivity}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noVaults"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
</div></section>
|
||||
{{end}}
|
||||
@@ -1,2 +1,2 @@
|
||||
{{define "confirm"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "confirm.title"}}</h1><p class="muted">{{t .Locale "confirm.description"}}</p><form method="post" action="/api/v1/auth/confirm" class="stack"><input type="hidden" name="token" value="{{.Token}}"><button class="button primary" type="submit">{{t .Locale "confirm.action"}}</button></form></section>{{end}}
|
||||
{{define "content"}}<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "confirm.title"}}</h1><p class="muted">{{t .Locale "confirm.description"}}</p><form method="post" action="/api/v1/auth/confirm" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><input type="hidden" name="token" value="{{.Token}}"><button class="button primary" type="submit">{{t .Locale "confirm.action"}}</button></form></section>{{end}}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
{{define "dashboard"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="dashboard-head"><div><p class="eyebrow">{{t .Locale "user.account"}}</p><h1>{{.UserName}}</h1><p class="muted">{{.Email}}</p></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="button secondary" type="submit">{{t .Locale "auth.logout"}}</button></form></section>{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}<section class="card table-card"><h2>{{t .Locale "user.devices"}}</h2>{{if .Devices}}<div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .Devices}}<tr><td>{{.Name}}</td><td><span class="mono">{{short .Vault 18}}</span></td><td>{{.ClientVersion}}</td><td>{{.LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form method="post" action="/api/v1/user/devices/{{.ID}}/revoke" class="inline-form"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><label class="sr-only">{{t $.Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "device.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{end}}</tbody></table></div>{{else}}<p class="empty">{{t .Locale "user.noDevices"}}</p>{{end}}</section>{{end}}
|
||||
{{define "content"}}
|
||||
<section class="dashboard-head"><div><p class="eyebrow">{{t .Locale "user.account"}}</p><h1>{{.UserName}}</h1><p class="muted">{{.Email}} · {{if .UserConfirmed}}<span class="badge ok">{{t .Locale "user.emailConfirmed"}}</span>{{else}}<span class="badge danger">{{t .Locale "user.emailUnconfirmed"}}</span>{{end}}</p></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRF}}"><button class="button secondary" type="submit">{{t .Locale "auth.logout"}}</button></form></section>
|
||||
{{if .Flash}}<p class="flash {{if .FlashError}}error{{else}}success{{end}}" role="{{if .FlashError}}alert{{else}}status{{end}}">{{t .Locale .Flash}}</p>{{end}}
|
||||
<section class="card panel"><p class="muted">{{t .Locale "user.connectInstruction"}}</p></section>
|
||||
<section class="card table-card"><div class="section-heading"><h2>{{t .Locale "user.devices"}}</h2><form class="list-filter" method="get" action="/dashboard"><label class="sr-only">{{t .Locale "user.filterDevices"}}</label><input name="q" value="{{.List.Query}}" placeholder="{{t .Locale "user.filterDevices"}}"><button class="button secondary" type="submit">{{t .Locale "admin.applyFilters"}}</button></form></div>{{if .Devices}}<div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.vault"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "device.created"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th><th>{{t .Locale "common.actions"}}</th></tr></thead><tbody>{{range .Devices}}<tr><td>{{.Name}}</td><td><span class="mono">{{short .Vault 18}}</span></td><td>{{.ClientVersion}}</td><td>{{webtime $.Locale .CreatedAt}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td><td>{{if not .Revoked}}<form method="post" action="/api/v1/user/devices/{{.ID}}/revoke" class="inline-form"><input type="hidden" name="csrf_token" value="{{$.CSRF}}"><label class="sr-only">{{t $.Locale "field.password"}}</label><input name="password" type="password" autocomplete="current-password" required placeholder="{{t $.Locale "field.password"}}"><button class="button danger" type="submit" data-confirm="{{t $.Locale "device.revokeConfirm"}}">{{t $.Locale "device.revoke"}}</button></form>{{end}}</td></tr>{{end}}</tbody></table></div>{{else}}<p class="empty">{{t .Locale "user.noDevices"}}</p>{{end}}</section>
|
||||
{{end}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "forgot"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.forgotTitle"}}</h1><p class="muted">{{t .Locale "auth.forgotDescription"}}</p>
|
||||
<form method="post" action="/forgot" class="stack"><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required autofocus></label><button class="button primary" type="submit">{{t .Locale "auth.sendLink"}}</button></form><p class="muted"><a href="/login">{{t .Locale "auth.backLogin"}}</a></p></section>
|
||||
<form method="post" action="/forgot" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required autofocus></label><button class="button primary" type="submit">{{t .Locale "auth.sendLink"}}</button></form><p class="muted"><a href="/login">{{t .Locale "auth.backLogin"}}</a></p></section>
|
||||
{{end}}
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
<h1>{{t .Locale "home.heading"}}</h1>
|
||||
<p class="lead">{{t .Locale "home.description"}}</p>
|
||||
<div class="actions"><a class="button primary" href="/login">{{t .Locale "home.login"}}</a>{{if .AllowRegistration}}<a class="button secondary" href="/register">{{t .Locale "home.register"}}</a>{{end}}</div>
|
||||
<p class="muted">{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · <span class="badge ok">{{.Status}}</span></p>
|
||||
<p class="muted">{{t .Locale "home.version"}} {{.Version}} · {{.BuildCommit}} · <span class="badge ok">{{statuslabel .Locale .Status}}</span></p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</nav>
|
||||
<form class="locale-form" action="/locale" method="post" aria-label="{{t .Locale "locale.label"}}">
|
||||
<input type="hidden" name="from" value="{{.CurrentURL}}">
|
||||
{{if .CSRF}}<input type="hidden" name="csrf_token" value="{{.CSRF}}">{{end}}
|
||||
<input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}">
|
||||
<label class="sr-only" for="locale">{{t .Locale "locale.label"}}</label>
|
||||
<select id="locale" name="locale" data-auto-submit>
|
||||
<option value="system" {{if eq .LocalePreference "system"}}selected{{end}}>{{t .Locale "locale.system"}}</option>
|
||||
@@ -29,6 +29,7 @@
|
||||
</header>
|
||||
<main class="page-shell">{{template "content" .}}</main>
|
||||
<footer class="site-footer"><span>{{.ServerName}}</span><span>{{t .Locale "footer.localFirst"}}</span></footer>
|
||||
<dialog id="confirm-dialog" class="confirm-dialog" aria-labelledby="confirm-dialog-title"><form method="dialog" class="stack"><h2 id="confirm-dialog-title">{{t .Locale "admin.confirmTitle"}}</h2><p id="confirm-dialog-message"></p><div class="actions"><button class="button secondary" value="cancel">{{t .Locale "admin.modalCancel"}}</button><button class="button danger" value="confirm">{{t .Locale "admin.modalConfirm"}}</button></div></form></dialog>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
{{define "content"}}
|
||||
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.welcome"}}</p><h1>{{t .Locale "auth.loginTitle"}}</h1>
|
||||
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
|
||||
<form method="post" action="/login" class="stack"><label>{{t .Locale "field.usernameOrEmail"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form>
|
||||
<form method="post" action="/login" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.usernameOrEmail"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="current-password" required></label><button class="button primary" type="submit">{{t .Locale "auth.login"}}</button></form>
|
||||
<p class="muted"><a href="/forgot">{{t .Locale "auth.forgot"}}</a>{{if .AllowRegistration}} · <a href="/register">{{t .Locale "auth.register"}}</a>{{end}}</p></section>
|
||||
{{end}}
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
{{define "content"}}
|
||||
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.registerTitle"}}</h1>
|
||||
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
|
||||
<form method="post" action="/register" class="stack"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.register"}}</button></form>
|
||||
<form method="post" action="/register" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><label>{{t .Locale "field.username"}}<input name="username" autocomplete="username" required autofocus></label><label>{{t .Locale "field.email"}}<input name="email" type="email" autocomplete="email" required></label><label>{{t .Locale "field.password"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.register"}}</button></form>
|
||||
<p class="muted"><a href="/login">{{t .Locale "auth.haveAccount"}}</a></p></section>
|
||||
{{end}}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
{{define "content"}}
|
||||
<section class="auth-card card"><p class="eyebrow">{{t .Locale "auth.account"}}</p><h1>{{t .Locale "auth.resetTitle"}}</h1>
|
||||
{{if .Flash}}<p class="flash error" role="alert">{{t .Locale .Flash}}</p>{{end}}
|
||||
<form method="post" action="/reset" class="stack"><input type="hidden" name="token" value="{{.Token}}"><label>{{t .Locale "field.newPassword"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required autofocus></label><label>{{t .Locale "field.confirmPassword"}}<input name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.savePassword"}}</button></form></section>
|
||||
<form method="post" action="/reset" class="stack"><input type="hidden" name="locale_csrf" value="{{.LocaleCSRF}}"><input type="hidden" name="token" value="{{.Token}}"><label>{{t .Locale "field.newPassword"}}<input name="password" type="password" autocomplete="new-password" minlength="8" maxlength="256" required autofocus></label><label>{{t .Locale "field.confirmPassword"}}<input name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="256" required></label><button class="button primary" type="submit">{{t .Locale "auth.savePassword"}}</button></form></section>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{{define "unavailable"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="message-card card"><p class="eyebrow">{{t .Locale "home.eyebrow"}}</p><h1>{{t .Locale .Heading}}</h1><p class="muted">{{t .Locale .Message}}</p></section>{{end}}
|
||||
@@ -1,2 +1,10 @@
|
||||
{{define "vault_detail"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}<section class="admin-shell"><div class="admin-content"><p class="eyebrow">{{t .Locale "admin.vaults"}}</p><h1 class="mono">{{short .VaultDetail.Vault 32}}</h1><p class="muted">{{.VaultDetail.User}}</p><div class="stat-grid"><article class="card stat"><strong>{{.VaultDetail.Devices}}</strong><span>{{t .Locale "admin.devices"}}</span></article><article class="card stat"><strong>{{.VaultDetail.Operations}}</strong><span>{{t .Locale "admin.operations"}}</span></article><article class="card stat"><strong>{{.VaultDetail.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article></div><section class="card panel"><dl class="details"><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{.VaultDetail.LastActivity}}</dd></dl><p class="muted">{{t .Locale "admin.vaultPrivacy"}}</p><a class="button secondary" href="/admin/vaults">{{t .Locale "common.back"}}</a></section></div></section>{{end}}
|
||||
{{define "content"}}
|
||||
<section class="admin-shell">{{template "admin_nav" .}}<div class="admin-content">
|
||||
<p class="eyebrow">{{t .Locale "admin.vaults"}}</p><h1 class="mono">{{short .VaultDetail.Vault 32}}</h1><p class="muted">{{.VaultDetail.User}}</p>
|
||||
<div class="stat-grid"><article class="card stat"><strong>{{.VaultDetail.Devices}}</strong><span>{{t .Locale "admin.devices"}} · {{.VaultDetail.Active}} {{t .Locale "admin.active"}} · {{.VaultDetail.Revoked}} {{t .Locale "admin.revoked"}}</span></article><article class="card stat"><strong>{{.VaultDetail.Operations}}</strong><span>{{t .Locale "admin.operations"}} · {{t .Locale "admin.sequence"}} {{.VaultDetail.Sequence}}</span></article><article class="card stat"><strong>{{webbytes .VaultDetail.BlobBytes}}</strong><span>{{t .Locale "admin.blobBytes"}}</span></article></div>
|
||||
<section class="card panel"><dl class="details"><dt>{{t .Locale "admin.lastActivity"}}</dt><dd>{{webtime .Locale .VaultDetail.LastActivity}}</dd></dl><p class="muted">{{t .Locale "admin.vaultPrivacy"}}</p></section>
|
||||
<section class="card table-card"><h2>{{t .Locale "admin.devices"}}</h2><div class="table-scroll"><table><thead><tr><th>{{t .Locale "device.name"}}</th><th>{{t .Locale "device.version"}}</th><th>{{t .Locale "admin.ip"}}</th><th>{{t .Locale "device.lastSeen"}}</th><th>{{t .Locale "device.status"}}</th></tr></thead><tbody>{{range .VaultDevices}}<tr><td>{{.Name}}</td><td>{{.Version}}</td><td>{{.LastIP}}</td><td>{{webtime $.Locale .LastSeen}}</td><td>{{if .Revoked}}<span class="badge danger">{{t $.Locale "device.revoked"}}</span>{{else}}<span class="badge ok">{{t $.Locale "device.active"}}</span>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="empty">{{t .Locale "admin.noDevices"}}</td></tr>{{end}}</tbody></table></div></section>
|
||||
<a class="button secondary" href="/admin/vaults">{{t .Locale "common.back"}}</a>
|
||||
</div></section>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user