Files
Levyraati26_go/templates/admin.html
T
Esa Kataja 2af29fe999 Make the admin a member with a flag, and drop the second listener
The admin was a set of env credentials on its own loopback listener. That
bought network isolation, and charged a second port to tunnel and proxy and
a second credential in the password manager. It also sat outside the
SameSite protection the member cookie already had, and left every ban and
password reset with no actor to log.

is_admin on users reuses what was already there: the session, the login
rate limiter, ban-drops-sessions, CSRF. /admin is now a route on the member
mux. A member without the flag gets 404 rather than 403 — the pages are
none of their business, and "forbidden" confirms there is something to be
forbidden from.

Registration needs an invite and invites come from /admin, so an empty
database cannot grow its first user. seedAdmin breaks that circle exactly
once, from ADMIN_EMAIL and ADMIN_PASSWORD, and does nothing against a
database that already has users.

An admin cannot ban themselves: banning drops the target's sessions, and
nothing would be left that could undo it.

This reverses decision 8, which is rewritten rather than deleted, along
with the admin entry in the CONTEXT.md vocabulary.
2026-09-05 13:40:26 +03:00

119 lines
4.3 KiB
HTML

{{define "content"}}
<h1>Ylläpito</h1>
<p><a href="/admin/reports">Palautteet</a>{{if .Data.OpenCount}} <span class="badge pending">{{.Data.OpenCount}} avointa</span>{{end}}</p>
<section class="adminsection">
<header>
<h2>Kutsut</h2>
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
</header>
<div class="body">
<table>
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
<tbody>
{{range .Data.Invites}}
<tr>
<!-- Not a link: an invite is something to send, never to follow. A click used to open the
join form in the admin's own browser, which is never what was wanted. -->
<td class="invitecell">
<code>{{.Link}}</code>
<button type="button" class="ghost" onclick="copyInvite(this)">Kopioi</button>
</td>
<td class="nowrap"><span class="dot on"></span> käyttämätön</td>
<td>{{fidate .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="3" class="muted">Ei käyttämättömiä kutsuja.</td></tr>
{{end}}
</tbody>
</table>
<p class="muted small">Lähetä linkki kaverille. Koodi on valmiiksi täytettynä.
Lista näyttää käyttämättömät kutsut{{if .Data.SpentCount}}; käytettyjä on
{{.Data.SpentCount}}{{end}}.</p>
</div>
</section>
<section class="adminsection">
<header><h2>Jäsenet</h2></header>
<div class="body">
<table>
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
<tbody>
{{range .Data.Members}}
<tr{{if .Banned}} class="banned"{{end}}>
<td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
<td>{{.Email}}</td>
<td>{{fidate .CreatedAt}}</td>
<td class="actions">
<form method="post" action="/admin/users/{{.ID}}/ban">
<button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
</form>
<form method="post" action="/admin/users/{{.ID}}/password">
<input type="password" name="password" placeholder="uusi salasana" required>
<button type="submit" class="ghost">Vaihda salasana</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="4" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
<section class="adminsection">
<header><h2>Kappaleet</h2></header>
<div class="body">
<table>
<thead><tr><th>Kappale</th><th>Lähettäjä</th><th>Arvostelut</th><th>Julkaistu</th><th></th></tr></thead>
<tbody>
{{range .Data.Songs}}
<tr>
<td>{{.Title}} <span class="muted">— {{.Artist}}</span></td>
<td>{{.Submitter}}</td>
<td>{{.Reviews}}</td>
<td class="nowrap">{{fidate .CreatedAt}}</td>
<td class="actions">
<a href="/audio/{{.ID}}">Kuuntele</a>
<form method="post" action="/admin/songs/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
<button type="submit" class="ghost danger">Poista</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Ei kappaleita.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
<script>
// The clipboard API needs a secure context. Over the documented SSH tunnel the origin is
// localhost, which qualifies; reached any other way it is missing, so selecting the text is the
// fallback — the admin presses Ctrl+C instead of being left with a button that does nothing.
function copyInvite(button) {
const link = button.previousElementSibling;
const done = () => {
button.textContent = 'Kopioitu';
setTimeout(() => { button.textContent = 'Kopioi'; }, 1500);
};
if (navigator.clipboard) {
navigator.clipboard.writeText(link.textContent).then(done, () => selectText(link));
} else {
selectText(link);
}
}
function selectText(el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
</script>
{{end}}