Widen the administration interface
This commit is contained in:
+79
-12
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,9 +33,19 @@ type adminObject struct {
|
|||||||
Size string
|
Size string
|
||||||
Bytes int64
|
Bytes int64
|
||||||
Owner string
|
Owner string
|
||||||
Created string
|
|
||||||
Expires string
|
|
||||||
Vanity bool
|
Vanity bool
|
||||||
|
|
||||||
|
// Times are kept three ways: the value itself, which is what sorting
|
||||||
|
// compares; an absolute rendering for the tooltip; and a relative one,
|
||||||
|
// which is what you actually read when deciding whether a file still
|
||||||
|
// matters. Sorting on the rendered string would tie everything that
|
||||||
|
// happened within the same minute.
|
||||||
|
createdAt time.Time
|
||||||
|
expiresAt *time.Time
|
||||||
|
Created string
|
||||||
|
CreatedAgo string
|
||||||
|
Expires string
|
||||||
|
ExpiresIn string
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminToken struct {
|
type adminToken struct {
|
||||||
@@ -48,13 +59,26 @@ type adminToken struct {
|
|||||||
// adminSorts maps the sort parameter to a comparison. Restricting to this set
|
// adminSorts maps the sort parameter to a comparison. Restricting to this set
|
||||||
// keeps the parameter from reaching anything that interprets it.
|
// keeps the parameter from reaching anything that interprets it.
|
||||||
var adminSorts = map[string]func(a, b adminObject) int{
|
var adminSorts = map[string]func(a, b adminObject) int{
|
||||||
"created": func(a, b adminObject) int { return cmp.Compare(b.Created, a.Created) },
|
"created": func(a, b adminObject) int { return b.createdAt.Compare(a.createdAt) },
|
||||||
"expires": func(a, b adminObject) int { return cmp.Compare(a.Expires, b.Expires) },
|
"expires": func(a, b adminObject) int { return compareExpiry(a.expiresAt, b.expiresAt) },
|
||||||
"size": func(a, b adminObject) int { return cmp.Compare(b.Bytes, a.Bytes) },
|
"size": func(a, b adminObject) int { return cmp.Compare(b.Bytes, a.Bytes) },
|
||||||
"name": func(a, b adminObject) int { return cmp.Compare(a.ID, b.ID) },
|
"name": func(a, b adminObject) int { return cmp.Compare(a.Filename, b.Filename) },
|
||||||
"owner": func(a, b adminObject) int { return cmp.Compare(a.Owner, b.Owner) },
|
"owner": func(a, b adminObject) int { return cmp.Compare(a.Owner, b.Owner) },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compareExpiry orders soonest first, with "never" last where it belongs.
|
||||||
|
func compareExpiry(a, b *time.Time) int {
|
||||||
|
switch {
|
||||||
|
case a == nil && b == nil:
|
||||||
|
return 0
|
||||||
|
case a == nil:
|
||||||
|
return 1
|
||||||
|
case b == nil:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return a.Compare(*b)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
lim, err := s.limitsFor(r, credential(r))
|
lim, err := s.limitsFor(r, credential(r))
|
||||||
switch {
|
switch {
|
||||||
@@ -92,15 +116,19 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Size: config.FormatBytes(m.Size),
|
Size: config.FormatBytes(m.Size),
|
||||||
Bytes: m.Size,
|
Bytes: m.Size,
|
||||||
Owner: m.Owner,
|
Owner: m.Owner,
|
||||||
Created: m.Created.UTC().Format(time.RFC3339),
|
|
||||||
Expires: expiresSortable(m.Expires),
|
|
||||||
Vanity: m.Vanity,
|
Vanity: m.Vanity,
|
||||||
|
createdAt: m.Created,
|
||||||
|
expiresAt: m.Expires,
|
||||||
|
Created: absolute(&m.Created),
|
||||||
|
CreatedAgo: relative(m.Created, now),
|
||||||
|
Expires: absolute(m.Expires),
|
||||||
|
ExpiresIn: expiresRelative(m.Expires, now),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
slices.SortStableFunc(objects, adminSorts[sortBy])
|
slices.SortStableFunc(objects, adminSorts[sortBy])
|
||||||
|
|
||||||
data := adminPage{
|
data := adminPage{
|
||||||
page: s.page(r, "Administration", true),
|
page: s.widePage(r, "Administration"),
|
||||||
Objects: objects,
|
Objects: objects,
|
||||||
Tokens: s.adminTokens(),
|
Tokens: s.adminTokens(),
|
||||||
Sort: sortBy,
|
Sort: sortBy,
|
||||||
@@ -139,11 +167,50 @@ func (s *Server) adminTokens() []adminToken {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// expiresSortable renders a deadline so that string ordering is chronological
|
// absolute renders a time for a tooltip, where the reader wants the real value
|
||||||
// and "never" sorts last.
|
// rather than a distance from now.
|
||||||
func expiresSortable(t *time.Time) string {
|
func absolute(t *time.Time) string {
|
||||||
if t == nil {
|
if t == nil {
|
||||||
return "never"
|
return "never"
|
||||||
}
|
}
|
||||||
return t.UTC().Format(time.RFC3339)
|
return t.UTC().Format("2006-01-02 15:04 MST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func expiresRelative(t *time.Time, now time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
return relative(*t, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// relative renders a time as a short distance from now - "3d ago", "in 4h" -
|
||||||
|
// which is what a listing is actually read for. The exact time stays available
|
||||||
|
// in the cell's tooltip.
|
||||||
|
func relative(t, now time.Time) string {
|
||||||
|
d := t.Sub(now)
|
||||||
|
ahead := d > 0
|
||||||
|
if !ahead {
|
||||||
|
d = -d
|
||||||
|
}
|
||||||
|
var magnitude string
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
magnitude = "now"
|
||||||
|
case d < time.Hour:
|
||||||
|
magnitude = fmt.Sprintf("%dm", int(d.Minutes()))
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
magnitude = fmt.Sprintf("%dh", int(d.Hours()))
|
||||||
|
case d < 365*24*time.Hour:
|
||||||
|
magnitude = fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||||
|
default:
|
||||||
|
magnitude = fmt.Sprintf("%dy", int(d.Hours()/24/365))
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case magnitude == "now":
|
||||||
|
return "now"
|
||||||
|
case ahead:
|
||||||
|
return "in " + magnitude
|
||||||
|
default:
|
||||||
|
return magnitude + " ago"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,11 @@ type page struct {
|
|||||||
// page agrees about who you are without any script involved.
|
// page agrees about who you are without any script involved.
|
||||||
User string
|
User string
|
||||||
Admin bool
|
Admin bool
|
||||||
|
|
||||||
|
// Wide widens the page for content that is a table rather than a form.
|
||||||
|
// The reading measure that suits the upload page is far too narrow for a
|
||||||
|
// listing, which otherwise ends up behind a horizontal scrollbar.
|
||||||
|
Wide bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// page builds the common fields, resolving the session so the header can show
|
// page builds the common fields, resolving the session so the header can show
|
||||||
@@ -261,3 +266,10 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg st
|
|||||||
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||||
s.fail(w, r, http.StatusNotFound, "No such page.")
|
s.fail(w, r, http.StatusNotFound, "No such page.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// widePage is page for content that is a listing rather than a form.
|
||||||
|
func (s *Server) widePage(r *http.Request, title string) page {
|
||||||
|
p := s.page(r, title, true)
|
||||||
|
p.Wide = true
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -1791,3 +1793,95 @@ func TestRotationEndsLiveSessions(t *testing.T) {
|
|||||||
t.Errorf("id = %q: the rotated token lost its vanity permission", res.ID)
|
t.Errorf("id = %q: the rotated token lost its vanity permission", res.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sorting has to compare the underlying values, not their rendered form: two
|
||||||
|
// uploads in the same minute render identically but are not equal.
|
||||||
|
func TestAdminSortOrdersByValue(t *testing.T) {
|
||||||
|
h := newHarness(t, nil)
|
||||||
|
|
||||||
|
// Three files, distinct in every sortable dimension.
|
||||||
|
type spec struct {
|
||||||
|
name string
|
||||||
|
size int
|
||||||
|
expiry string
|
||||||
|
}
|
||||||
|
for i, s := range []spec{
|
||||||
|
{"big", 300, "3h"},
|
||||||
|
{"small", 10, "1h"},
|
||||||
|
{"medium", 100, "2h"},
|
||||||
|
} {
|
||||||
|
h.now = clock.Add(time.Duration(i) * time.Second) // same minute, distinct instants
|
||||||
|
resp := h.upload(t, bytes.Repeat([]byte("x"), s.size), map[string]string{
|
||||||
|
"Authorization": "Bearer " + h.token,
|
||||||
|
"Vanity": s.name,
|
||||||
|
"Expiry": s.expiry,
|
||||||
|
"Content-Disposition": `attachment; filename="` + s.name + `.bin"`,
|
||||||
|
})
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
h.now = clock
|
||||||
|
|
||||||
|
order := func(sortBy string) []string {
|
||||||
|
resp := h.get(t, "/admin?sort="+sortBy, h.admin)
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
var ids []string
|
||||||
|
for _, m := range regexp.MustCompile(`class="id mono">([a-z]+)`).FindAllStringSubmatch(string(raw), -1) {
|
||||||
|
ids = append(ids, m[1])
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
sortBy string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"size", []string{"big", "medium", "small"}}, // largest first
|
||||||
|
{"expires", []string{"small", "medium", "big"}}, // soonest first
|
||||||
|
{"created", []string{"medium", "small", "big"}}, // newest first
|
||||||
|
{"name", []string{"big", "medium", "small"}}, // by filename
|
||||||
|
} {
|
||||||
|
got := order(c.sortBy)
|
||||||
|
if !slices.Equal(got, c.want) {
|
||||||
|
t.Errorf("sort=%s gave %v, want %v", c.sortBy, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The listing is a table, and a table needs more room than a form. It also has
|
||||||
|
// to stop being a table on a narrow screen rather than grow a scrollbar.
|
||||||
|
func TestAdminPageIsLaidOutForATable(t *testing.T) {
|
||||||
|
h := newHarness(t, nil)
|
||||||
|
h.upload(t, []byte("x"), nil).Body.Close()
|
||||||
|
|
||||||
|
resp := h.get(t, "/admin", h.admin)
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
page := string(raw)
|
||||||
|
|
||||||
|
if !strings.Contains(page, `class="wide"`) {
|
||||||
|
t.Error("the listing renders at the narrow reading measure meant for forms")
|
||||||
|
}
|
||||||
|
// Every cell needs its label for the stacked layout, where the header row
|
||||||
|
// is hidden.
|
||||||
|
for _, label := range []string{"Size", "Owner", "Uploaded", "Expires"} {
|
||||||
|
if !strings.Contains(page, `data-label="`+label+`"`) {
|
||||||
|
t.Errorf("cells carry no %q label, so the stacked layout loses its headings", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
css := h.get(t, "/static/style.css", "")
|
||||||
|
cssRaw, _ := io.ReadAll(css.Body)
|
||||||
|
css.Body.Close()
|
||||||
|
style := string(cssRaw)
|
||||||
|
|
||||||
|
if !strings.Contains(style, "body.wide") {
|
||||||
|
t.Error("no wide layout is defined")
|
||||||
|
}
|
||||||
|
if !strings.Contains(style, "@media (max-width: 46rem)") {
|
||||||
|
t.Error("no narrow-screen rule, so the table will scroll sideways on a phone")
|
||||||
|
}
|
||||||
|
if strings.Contains(style, "overflow-x: auto") && strings.Contains(page, "tablewrap") {
|
||||||
|
t.Error("the listing still relies on a horizontal scroll container")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+56
-30
@@ -202,50 +202,76 @@ header nav form { display: inline; }
|
|||||||
header nav .who { color: var(--muted); }
|
header nav .who { color: var(--muted); }
|
||||||
header nav .who::before { content: "\1F511\00a0"; }
|
header nav .who::before { content: "\1F511\00a0"; }
|
||||||
|
|
||||||
|
/* A listing needs far more room than the reading measure a form wants. */
|
||||||
|
body.wide header,
|
||||||
|
body.wide main,
|
||||||
|
body.wide footer { max-width: 72rem; }
|
||||||
|
|
||||||
dl.stats { grid-template-columns: auto 1fr; }
|
dl.stats { grid-template-columns: auto 1fr; }
|
||||||
dl.stats em { font-style: normal; color: var(--muted); }
|
dl.stats em { font-style: normal; color: var(--muted); }
|
||||||
|
|
||||||
/* Tables scroll on their own rather than making the page scroll sideways. */
|
|
||||||
.tablewrap { overflow-x: auto; margin: 0 -.25rem; }
|
|
||||||
|
|
||||||
table.admin { border-collapse: collapse; width: 100%; font-size: .8125rem; }
|
table.admin { border-collapse: collapse; width: 100%; font-size: .8125rem; }
|
||||||
table.admin th, table.admin td { padding: .4rem .5rem; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }
|
table.admin th,
|
||||||
|
table.admin td {
|
||||||
|
padding: .45rem .5rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
table.admin th { color: var(--muted); font-weight: 500; white-space: nowrap; }
|
table.admin th { color: var(--muted); font-weight: 500; white-space: nowrap; }
|
||||||
table.admin th a { color: inherit; text-decoration: none; }
|
table.admin th a { color: inherit; text-decoration: none; }
|
||||||
table.admin th a:hover { color: var(--accent); text-decoration: underline; }
|
table.admin th a:hover { color: var(--accent); text-decoration: underline; }
|
||||||
table.admin tr:last-child td { border-bottom: 0; }
|
table.admin tr:last-child td { border-bottom: 0; }
|
||||||
table.admin .num { text-align: right; white-space: nowrap; }
|
table.admin .num { text-align: right; }
|
||||||
table.admin .small { font-size: .75rem; color: var(--muted); white-space: nowrap; }
|
table.admin .nowrap { white-space: nowrap; }
|
||||||
table.admin .wrap { overflow-wrap: anywhere; min-width: 8rem; }
|
|
||||||
table.admin form { margin: 0; }
|
table.admin form { margin: 0; }
|
||||||
|
table.admin .actions-cell { text-align: right; width: 1%; }
|
||||||
|
|
||||||
|
/* The filename carries the link and the identifier sits under it, which saves
|
||||||
|
a whole column of UUID. */
|
||||||
|
table.admin .filecell a { overflow-wrap: anywhere; font-weight: 500; }
|
||||||
|
table.admin .filecell .id {
|
||||||
|
display: block;
|
||||||
|
font-size: .6875rem;
|
||||||
|
color: var(--muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
button.small { padding: .2rem .5rem; font-size: .75rem; }
|
button.small { padding: .2rem .5rem; font-size: .75rem; }
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
margin: -1px; padding: 0; border: 0;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Narrow screens get one card per row rather than a horizontal scrollbar,
|
||||||
|
which is unusable for picking a row and pressing a button in it. */
|
||||||
|
@media (max-width: 46rem) {
|
||||||
|
table.admin,
|
||||||
|
table.admin tbody,
|
||||||
|
table.admin tr,
|
||||||
|
table.admin td { display: block; width: 100%; }
|
||||||
|
table.admin thead { display: none; }
|
||||||
|
table.admin tr {
|
||||||
|
padding: .625rem 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
table.admin td { border: 0; padding: .1rem 0; }
|
||||||
|
table.admin td[data-label]::before {
|
||||||
|
content: attr(data-label) ": ";
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
table.admin .num,
|
||||||
|
table.admin .actions-cell { text-align: left; width: auto; }
|
||||||
|
table.admin .actions-cell { padding-top: .5rem; }
|
||||||
|
table.admin .filecell::before { content: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.cli code, .hint code { background: var(--bg); padding: .1rem .3rem; border-radius: 4px; }
|
.cli code, .hint code { background: var(--bg); padding: .1rem .3rem; border-radius: 4px; }
|
||||||
|
|
||||||
/* Disclosure sections and inline errors ----------------------------------- */
|
|
||||||
|
|
||||||
details > summary {
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: .125rem 0;
|
|
||||||
}
|
|
||||||
details[open] > summary { margin-bottom: .75rem; }
|
|
||||||
details .field { margin-top: .75rem; max-width: 28rem; }
|
|
||||||
|
|
||||||
p.error {
|
|
||||||
margin: 0 0 .75rem;
|
|
||||||
padding: .5rem .75rem;
|
|
||||||
color: var(--danger);
|
|
||||||
background: var(--warn-bg);
|
|
||||||
border: 1px solid var(--danger);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: .875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card .actions { margin: 1.25rem 0; }
|
|
||||||
.card > .field:last-child { margin-bottom: 0; }
|
|
||||||
|
|
||||||
/* One-off token, tucked away so the common path stays a single button. */
|
/* One-off token, tucked away so the common path stays a single button. */
|
||||||
.onceoff { margin-bottom: 1rem; }
|
.onceoff { margin-bottom: 1rem; }
|
||||||
.onceoff summary { font-weight: 400; font-size: .875rem; color: var(--muted); }
|
.onceoff summary { font-weight: 400; font-size: .875rem; color: var(--muted); }
|
||||||
|
|||||||
+21
-24
@@ -13,52 +13,50 @@
|
|||||||
{{if not .Objects}}
|
{{if not .Objects}}
|
||||||
<p class="hint">Nothing stored right now.</p>
|
<p class="hint">Nothing stored right now.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="tablewrap">
|
<table class="admin files">
|
||||||
<table class="admin">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th><a href="?sort=name">Name</a></th>
|
<th><a href="?sort=name">File</a></th>
|
||||||
<th><a href="?sort=name">Filename</a></th>
|
|
||||||
<th class="num"><a href="?sort=size">Size</a></th>
|
<th class="num"><a href="?sort=size">Size</a></th>
|
||||||
<th><a href="?sort=owner">Owner</a></th>
|
<th><a href="?sort=owner">Owner</a></th>
|
||||||
<th><a href="?sort=created">Uploaded</a></th>
|
<th><a href="?sort=created">Uploaded</a></th>
|
||||||
<th><a href="?sort=expires">Expires</a></th>
|
<th><a href="?sort=expires">Expires</a></th>
|
||||||
<th></th>
|
<th><span class="visually-hidden">Actions</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Objects}}
|
{{range .Objects}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono"><a href="{{$.Base}}i/{{.ID}}">{{.ID}}</a>{{if .Vanity}} <em title="custom name">★</em>{{end}}</td>
|
<td data-label="File" class="filecell">
|
||||||
<td class="wrap">{{.Filename}}</td>
|
<a href="{{$.Base}}i/{{.ID}}">{{.Filename}}</a>
|
||||||
<td class="num">{{.Size}}</td>
|
<span class="id mono">{{.ID}}{{if .Vanity}} <em title="custom name">★</em>{{end}}</span>
|
||||||
<td>{{if .Owner}}{{.Owner}}{{else}}<em>anonymous</em>{{end}}</td>
|
</td>
|
||||||
<td class="mono small">{{.Created}}</td>
|
<td data-label="Size" class="num nowrap">{{.Size}}</td>
|
||||||
<td class="mono small">{{.Expires}}</td>
|
<td data-label="Owner">{{if .Owner}}{{.Owner}}{{else}}<em>anonymous</em>{{end}}</td>
|
||||||
<td>
|
<td data-label="Uploaded" class="nowrap" title="{{.Created}}">{{.CreatedAgo}}</td>
|
||||||
|
<td data-label="Expires" class="nowrap" title="{{.Expires}}">{{.ExpiresIn}}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
<form method="post" action="{{$.Base}}api/d/{{.ID}}/delete">
|
<form method="post" action="{{$.Base}}api/d/{{.ID}}/delete">
|
||||||
<input type="hidden" name="from" value="admin">
|
<input type="hidden" name="from" value="admin">
|
||||||
<button type="submit" class="danger small"
|
<button type="submit" class="danger small"
|
||||||
data-confirm="Delete {{.Filename}}?">Delete</button>
|
data-confirm="Delete {{.Filename}}? This cannot be undone.">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Tokens</h2>
|
<h2>Tokens</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Tokens are minted and revoked with <code>send token</code> on the server.
|
Tokens are minted, rotated and revoked with <code>./send token</code> on the
|
||||||
They are deliberately not manageable from here: nothing that hands out
|
server. They are deliberately not manageable from here: nothing that hands
|
||||||
credentials should be reachable over the network.
|
out credentials should be reachable over the network.
|
||||||
</p>
|
</p>
|
||||||
{{if .Tokens}}
|
{{if .Tokens}}
|
||||||
<div class="tablewrap">
|
|
||||||
<table class="admin">
|
<table class="admin">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>Max size</th><th>Max lifetime</th><th>Vanity</th><th>Admin</th></tr>
|
<tr><th>Name</th><th>Max size</th><th>Max lifetime</th><th>Vanity</th><th>Admin</th></tr>
|
||||||
@@ -66,16 +64,15 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .Tokens}}
|
{{range .Tokens}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.Name}}</td>
|
<td data-label="Name">{{.Name}}</td>
|
||||||
<td>{{.MaxSize}}</td>
|
<td data-label="Max size" class="nowrap">{{.MaxSize}}</td>
|
||||||
<td>{{.MaxExpiry}}</td>
|
<td data-label="Max lifetime" class="nowrap">{{.MaxExpiry}}</td>
|
||||||
<td>{{if .Vanity}}yes{{else}}no{{end}}</td>
|
<td data-label="Vanity">{{if .Vanity}}yes{{else}}no{{end}}</td>
|
||||||
<td>{{if .Admin}}yes{{else}}no{{end}}</td>
|
<td data-label="Admin">{{if .Admin}}yes{{else}}no{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>{{.Title}} · Uncensored Send</title>
|
<title>{{.Title}} · Uncensored Send</title>
|
||||||
<link rel="stylesheet" href="{{.Base}}static/style.css">
|
<link rel="stylesheet" href="{{.Base}}static/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body data-base="{{.Base}}">
|
<body data-base="{{.Base}}"{{if .Wide}} class="wide"{{end}}>
|
||||||
<header>
|
<header>
|
||||||
<a class="brand" href="{{.Base}}">Uncensored Send</a>
|
<a class="brand" href="{{.Base}}">Uncensored Send</a>
|
||||||
<nav>
|
<nav>
|
||||||
|
|||||||
Reference in New Issue
Block a user