Member invites, gallery resync, content and operator docs

- Invite flow: admin issue/revoke on join requests, public single-use
  claim links (hash-only tokens, 7-day expiry, no state reveal), claim
  creates the Member account and marks the request accepted
- Gallery: admin status page plus resync endpoint; sync refreshes
  thumbnails whose content changed; tools/gallery contract and checker
- Docs: public content page, admin-only operator runbook, api.md
  invite/gallery sections, all routes in the live API docs, docs
  reachability gate test
- Screenshots cover the new pages; version 1.0.18
This commit is contained in:
2026-10-06 02:56:08 +02:00
parent e512556703
commit 9fb4d65787
36 changed files with 1147 additions and 19 deletions
+18
View File
@@ -0,0 +1,18 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Gallery</h1>
<div class="stat-cards">
<div class="stat-card"><span class="value">{{ flyers }}</span><span class="name">Flyers</span></div>
<div class="stat-card"><span class="value">{{ memes }}</span><span class="name">Memes</span></div>
</div>
{% if missing %}
<div class="notice error" role="alert"><p>Missing source files:</p><ul>{% for filename in missing %}<li><code>{{ filename }}</code></li>{% endfor %}</ul></div>
{% else %}
<p class="muted">All catalogued source files are present.</p>
{% endif %}
<form method="post" action="/admin/gallery/resync">
<div class="actions"><button class="button" type="submit" data-loading>Resync gallery</button></div>
</form>
<p class="muted">Resync re-reads the source files, refreshes changed thumbnails and retires removed entries. New works arrive through deploy first.</p>
{% endblock %}
+3 -1
View File
@@ -7,6 +7,8 @@
<div class="stat-card"><span class="value">{{ open_joins }}</span><span class="name">Open join requests</span></div>
<div class="stat-card"><span class="value">{{ user_count }}</span><span class="name">Accounts</span></div>
<div class="stat-card"><span class="value">{{ deleted_count }}</span><span class="name">In trash</span></div>
<div class="stat-card"><span class="value">{{ flyer_count }}</span><span class="name">Flyers</span></div>
<div class="stat-card"><span class="value">{{ meme_count }}</span><span class="name">Memes</span></div>
</div>
<div class="actions"><a class="button" href="/admin/posts/new">New post</a><a class="button ghost" href="/admin/joins">View join requests</a></div>
<div class="actions"><a class="button" href="/admin/posts/new">New post</a><a class="button ghost" href="/admin/joins">View join requests</a><a class="button ghost" href="/admin/gallery">Gallery</a></div>
{% endblock %}
@@ -0,0 +1,9 @@
<!-- retoor <retoor@molodetz.nl> -->
{% extends "admin/base_admin.html" %}
{% block admin_content %}
<h1>Invite issued</h1>
<p>Send this link to <strong>{{ join.name }}</strong> ({{ join.contact }}). It is shown once and expires {{ local_dt(expires_at) }}.</p>
<div class="notice"><p><code>{{ claim_url }}</code></p></div>
<p class="muted">Issuing a new invite revokes this one. The request flips to accepted when the link is claimed.</p>
<p><a class="button ghost" href="/admin/joins">Back to join requests</a></p>
{% endblock %}
+3 -1
View File
@@ -12,7 +12,7 @@
<td>{{ item.contact }}</td>
<td>{% if item.repo_url %}<a href="{{ item.repo_url }}" rel="nofollow noopener" target="_blank">{{ item.repo_url }}</a>{% if item.link_status %}<br><span class="muted">{{ item.link_status }}{% if item.link_title %}: {{ item.link_title }}{% endif %}</span>{% endif %}{% else %}<span class="muted">none</span>{% endif %}</td>
<td>{{ item.message }}</td>
<td><span class="status {{ item.status }}">{{ item.status }}</span></td>
<td><span class="status {{ item.status }}">{{ item.status }}</span>{% if item.invite_open %}<br><span class="muted">invite until {{ local_dt(item.invite_expires_at) }}</span>{% endif %}</td>
<td>{{ local_dt(item.created_at) }}</td>
<td class="actions">
<form method="post" action="/admin/joins/{{ item.uid }}/status">
@@ -20,6 +20,8 @@
<button class="button ghost small" type="submit">Set</button>
</form>
{% if item.repo_url %}<form method="post" action="/admin/joins/{{ item.uid }}/check"><button class="button ghost small" type="submit" data-loading>Check link</button></form>{% endif %}
{% if item.invite_open %}<form method="post" action="/admin/joins/{{ item.uid }}/invite/revoke"><button class="button ghost small" type="submit">Revoke invite</button></form>
{% elif item.status != 'declined' %}<form method="post" action="/admin/joins/{{ item.uid }}/invite"><button class="button ghost small" type="submit" data-loading>Issue invite</button></form>{% endif %}
</td>
</tr>
{% else %}