OXYGENHUB

Administration is a sectioned page with a left-hand menu e8e7158bbc73

One section each for accounts, registration and invitations,
organisations, sign-in providers, mail, variables, secrets, AI
assistants and the audit log, with the menu the repository and
organisation settings use, and links to Runners and Storage. The audit
log pages fifty rows at a time. closes #110
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 19:01 UTC
Signature
✔ signed by Andy McCall
Hash
e8e7158bbc737f4bbfd2ca422e6a8b2742f5586a81997c4c439aec7b411139ac
Depends on
19bb31ff1f5c 2a83f5303458 3556d08e17f9 83940d53664f 89e89836cae7 b6b7e4cf6b24 e71846aa07b9
Depended on by
1062a0f8e985 737de0fac722 5d90e0e045e2 3dd6582de060 cd46e56b1709 1e8a6e8bb91b 757195ad7a5d 6ca0b971ff07 155cab142b63
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
22 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/hub/src/db/accounts.rs +10 −0
…
773 unchanged lines
774 774
    Ok(())
775 775
}
776 776
777 +
/// A page of the audit log, newest first, older than `before` (an id) when given.
778 +
pub async fn audit_page(pool: &Pool, before: Option<i64>, limit: i64) -> sqlx::Result<Vec<AuditRow>> {
779 +
    sqlx::query_as("SELECT id, at_ms, actor, action, target, detail FROM audit WHERE (? IS NULL OR id < ?) ORDER BY id DESC LIMIT ?")
780 +
        .bind(before)
781 +
        .bind(before)
782 +
        .bind(limit)
783 +
        .fetch_all(pool)
784 +
        .await
785 +
}
786 +
777 787
pub async fn audit_rows(pool: &Pool, limit: i64) -> sqlx::Result<Vec<AuditRow>> {
778 788
    sqlx::query_as("SELECT id, at_ms, actor, action, target, detail FROM audit ORDER BY at_ms DESC, id DESC LIMIT ?")
779 789
        .bind(limit)
…
141 unchanged lines
modified crates/hub/src/web/admin.rs +28 −3
…
27 unchanged lines
28 28
    mail_from: String,
29 29
    mail_enabled: bool,
30 30
    my_email: String,
31 +
    /// The audit log's next page: rows older than this id (#110).
32 +
    audit_older: Option<i64>,
31 33
    /// The site's AI assistants and prompt (#97).
32 34
    assistants: super::assistants::Panel,
33 35
    /// The site's Flow variables (#102).
…
12 unchanged lines
46 48
}
47 49
48 50
async fn admin_page(app: &Shared, shell: Shell, notice: Option<String>) -> Result<Page<AdminPage>, Error> {
51 +
    admin_page_at(app, shell, notice, None).await
52 +
}
53 +
54 +
/// The audit log shows this many rows a page.
55 +
const AUDIT_PAGE: i64 = 50;
56 +
57 +
async fn admin_page_at(app: &Shared, shell: Shell, notice: Option<String>, audit_before: Option<i64>) -> Result<Page<AdminPage>, Error> {
49 58
    let e = |e| Error::from_db(shell.clone(), e);
50 59
    let accounts_list = accounts::accounts(&app.db).await.map_err(e)?;
51 60
    let orgs = accounts::orgs(&app.db).await.map_err(e)?;
52 61
    let mode = super::session::registration_mode(app).await;
53 62
    let invitations = accounts::invitations(&app.db).await.map_err(e)?;
54 -
    let audit = accounts::audit_rows(&app.db, 100).await.map_err(e)?;
63 +
    let mut audit = accounts::audit_page(&app.db, audit_before, AUDIT_PAGE + 1).await.map_err(e)?;
64 +
    let audit_older = if audit.len() as i64 > AUDIT_PAGE {
65 +
        audit.truncate(AUDIT_PAGE as usize);
66 +
        audit.last().map(|r| r.id)
67 +
    } else {
68 +
        None
69 +
    };
55 70
    let repos = crate::db::repos::list(&app.db).await.map_err(e)?.len();
56 71
    let mail = crate::mail::settings(app);
57 72
    let my_email = match shell.user.as_deref() {
…
21 unchanged lines
79 94
        mail_from: mail.from.clone(),
80 95
        mail_enabled: !mail.smtp.is_empty() && !mail.from.is_empty(),
81 96
        my_email,
97 +
        audit_older,
82 98
    }))
83 99
}
84 100
…
64 unchanged lines
149 165
    admin_page(&app, shell, Some(notice)).await
150 166
}
151 167
152 -
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AdminPage>, Error> {
168 +
#[derive(Deserialize)]
169 +
pub struct IndexQuery {
170 +
    audit_before: Option<i64>,
171 +
}
172 +
173 +
pub async fn index(
174 +
    State(app): State<Shared>,
175 +
    viewer: Viewer,
176 +
    axum::extract::Query(q): axum::extract::Query<IndexQuery>,
177 +
) -> Result<Page<AdminPage>, Error> {
153 178
    let shell = Shell::new(&app, "/admin", &viewer);
154 179
    require_admin(&viewer, &shell)?;
155 -
    admin_page(&app, shell, None).await
180 +
    admin_page_at(&app, shell, None, q.audit_before).await
156 181
}
157 182
158 183
#[derive(Deserialize)]
…
277 unchanged lines
modified crates/hub/tests/accounts.rs +21 −0
…
981 unchanged lines
982 982
    assert!(no.location.as_deref().unwrap_or("").contains("registration%20is%20closed"), "{:?}", no.location);
983 983
    assert!(no.cookie.is_none());
984 984
}
985 +
986 +
#[tokio::test(flavor = "multi_thread")]
987 +
async fn administration_is_sectioned_and_its_audit_log_pages() {
988 +
    let h = hub().await;
989 +
    let app = &h.app;
990 +
    let andy = register(app, "andy", "").await;
991 +
    let andy_row = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap();
992 +
    for i in 0..60 {
993 +
        db::accounts::audit(&app.db, Some(&andy_row), "test.event", &format!("t{i}"), "").await.unwrap();
994 +
    }
995 +
    let page = get(app, "/admin", Some(&andy)).await.body;
996 +
    for id in ["accounts", "registration", "organisations", "signin", "mail", "variables", "secrets", "assistants", "audit"] {
997 +
        assert!(page.contains(&format!("href=\"#{id}\"")) && page.contains(&format!("id=\"{id}\"")), "section {id}: {page}");
998 +
    }
999 +
    assert!(page.contains("class=\"section-nav\""));
1000 +
    assert!(page.contains(">t59<") && !page.contains(">t5<"), "the newest page only");
1001 +
    let i = page.find("audit_before=").expect("an older link") + "audit_before=".len();
1002 +
    let before: String = page[i..].chars().take_while(|c| c.is_ascii_digit()).collect();
1003 +
    let older = get(app, &format!("/admin?audit_before={before}"), Some(&andy)).await.body;
1004 +
    assert!(older.contains(">t5<") && !older.contains(">t59<"), "the next page: {older}");
1005 +
}
modified templates/admin.html +49 −30
…
4 unchanged lines
5 5
{% block title %}Administration{% endblock %}
6 6
{% block content %}
7 7
<div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div>
8 -
<p><a class="go small" href="/admin/storage">Storage and maintenance</a> <a class="go small" href="/admin/runners">Runners</a></p>
8 +
<div class="sectioned">
9 +
<nav class="section-nav" aria-label="Sections">
10 +
  <a href="#accounts">Accounts</a>
11 +
  <a href="#registration">Registration</a>
12 +
  <a href="#organisations">Organisations</a>
13 +
  <a href="#signin">Sign in with</a>
14 +
  <a href="#mail">Mail</a>
15 +
  <a href="#variables">Variables</a>
16 +
  <a href="#secrets">Secrets</a>
17 +
  <a href="#assistants">AI assistants</a>
18 +
  <a href="#audit">Audit log</a>
19 +
  <a href="/admin/runners">Runners ↗</a>
20 +
  <a href="/admin/storage">Storage and maintenance ↗</a>
21 +
</nav>
22 +
<div class="sections">
9 23
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
10 -
<div class="side-layout">
11 -
<div>
12 -
<section class="panel">
24 +
<section class="panel" id="accounts">
13 25
  <h2>Accounts</h2>
14 -
  <table class="small"><tr><th>Username</th><th>Email</th><th>Flags</th><th>Last seen</th><th></th></tr>
26 +
  <div class="table"><table class="small"><tr><th>Username</th><th>Email</th><th>Flags</th><th>Last seen</th><th></th></tr>
15 27
  {% for a in accounts %}
16 28
  <tr><td><a href="/{{ a.username }}">{{ a.username }}</a></td><td>{{ a.email }}{% if !a.email.is_empty() %} <span class="small mute" title="{% if a.email_verified %}verified{% else %}not verified{% endif %}">{% if a.email_verified %}✔{% else %}○{% endif %}</span>{% endif %}</td><td>{% if a.is_admin %}<span class="tag">admin</span>{% endif %}{% if a.banned %} <span class="tag bad">banned</span>{% endif %}{% if a.disabled %} <span class="tag warn">disabled</span>{% endif %}{% if a.totp_enabled() %} <span class="tag">2fa</span>{% endif %}</td>
17 29
  <td>{% if a.last_seen_ms > 0 %}<time title="{{ a.last_seen_ms|when_i }}">{{ a.last_seen_ms|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td>
…
6 unchanged lines
24 36
    <label class="small">Deleting removes the account, its tokens, keys and memberships for good; what it wrote stays under its name, which stays retired. Type <strong>{{ a.username }}</strong> to confirm:
25 37
    <input name="confirm" autocomplete="off" required></label> <button class="danger" name="action" value="delete">delete {{ a.username }}</button></form></details></td></tr>
26 38
  {% endfor %}
27 -
  </table>
39 +
  </table></div>
28 40
</section>
29 -
<section class="panel">
30 -
  <h2>Audit log</h2>
31 -
  <table class="small"><tr><th>When</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr>
32 -
  {% for r in audit %}<tr><td><time title="{{ r.at_ms|when_i }}">{{ r.at_ms|ago_i }}</time></td><td>{{ r.actor }}</td><td>{{ r.action }}</td><td>{{ r.target }}</td><td class="mute">{{ r.detail }}</td></tr>{% endfor %}
33 -
  </table>
34 -
</section>
35 -
</div>
36 -
<aside class="side">
37 -
  <h3>Registration</h3>
41 +
<section class="panel" id="registration">
42 +
  <h2>Registration and invitations</h2>
38 43
  <form class="card" method="post" action="/admin/registration">
39 44
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
40 -
    <select name="mode">{% for m in ["open", "invite", "closed"] %}<option value="{{ m }}"{% if mode.as_str() == *m %} selected{% endif %}>{{ m }}</option>{% endfor %}</select>
45 +
    <label for="regmode">Who may register</label>
46 +
    <select id="regmode" name="mode">{% for m in ["open", "invite", "closed"] %}<option value="{{ m }}"{% if mode.as_str() == *m %} selected{% endif %}>{{ m }}</option>{% endfor %}</select>
41 47
    <button type="submit">SET</button>
42 48
  </form>
43 -
  <h3>Invitations</h3>
44 49
  <form class="card" method="post" action="/admin/invite"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">NEW INVITATION</button></form>
45 -
  <ul class="plain small">{% for (code, used, at) in invitations %}<li><code>{{ code }}</code> {% if used.is_some() %}<span class="mute">used</span>{% else %}<a href="/register?invite={{ code }}">link</a>{% endif %} · {{ at|ago_i }}</li>{% endfor %}</ul>
46 -
  <h3>Organisations</h3>
47 -
  <ul class="plain">{% for o in orgs %}<li><a href="/orgs/{{ o.name }}/settings">{{ o.name }}</a></li>{% endfor %}</ul>
48 -
  <h3>Sign in with</h3>
50 +
  {% if !invitations.is_empty() %}<ul class="plain small">{% for (code, used, at) in invitations %}<li><code>{{ code }}</code> {% if used.is_some() %}<span class="mute">used</span>{% else %}<a href="/register?invite={{ code }}">link</a>{% endif %} · {{ at|ago_i }}</li>{% endfor %}</ul>{% endif %}
51 +
</section>
52 +
<section class="panel" id="organisations">
53 +
  <h2>Organisations</h2>
54 +
  {% if orgs.is_empty() %}<p class="mute small">None yet.</p>{% else %}<ul class="plain">{% for o in orgs %}<li><img class="avatar xs" src="/avatar/{{ o.name }}" alt=""> <a href="/{{ o.name }}">{{ o.name }}</a> · <a class="small" href="/orgs/{{ o.name }}/settings">settings</a></li>{% endfor %}</ul>{% endif %}
55 +
</section>
56 +
<section class="panel" id="signin">
57 +
  <h2>Sign in with</h2>
58 +
  <p class="small mute">A first sign-in links the account with the same verified email, or makes an account when registration is open. Secrets are kept sealed.</p>
49 59
  {% for p in providers %}
50 60
  <form class="card" method="post" action="/admin/signin">
51 61
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="provider" value="{{ p.id }}">
52 -
    <h4>{{ p.name }} {% if p.enabled && p.has_secret && !p.client_id.is_empty() %}<span class="tag head">on</span>{% else %}<span class="tag dim">off</span>{% endif %}</h4>
62 +
    <h3>{{ p.name }} {% if p.enabled && p.has_secret && !p.client_id.is_empty() %}<span class="tag head">on</span>{% else %}<span class="tag dim">off</span>{% endif %}</h3>
53 63
    <label for="cid-{{ p.id }}">Client id</label><input type="text" id="cid-{{ p.id }}" name="client_id" value="{{ p.client_id }}" autocomplete="off">
54 64
    <label for="sec-{{ p.id }}">Client secret</label><input type="password" id="sec-{{ p.id }}" name="secret" placeholder="{% if p.has_secret %}unchanged{% else %}none{% endif %}" autocomplete="new-password">
55 65
    <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if p.enabled %} checked{% endif %}> on</label></div>
…
1 unchanged lines
57 67
    <button type="submit">SAVE</button>
58 68
  </form>
59 69
  {% endfor %}
60 -
  <p class="small mute">A first sign-in links the account with the same verified email, or makes an account when registration is open. Secrets are kept sealed.</p>
61 -
  <h3>Mail</h3>
70 +
</section>
71 +
<section class="panel" id="mail">
72 +
  <h2>Mail</h2>
62 73
  <form class="card" method="post" action="/admin/mail">
63 74
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
64 -
    <label>SMTP URL</label>
65 -
    <input type="text" name="smtp" value="{{ mail_smtp }}" placeholder="smtps://user:[email protected]:465">
66 -
    <label>From</label>
67 -
    <input type="text" name="from" value="{{ mail_from }}" placeholder="OxygenHub &lt;[email protected]&gt;">
75 +
    <label for="smtp">SMTP URL</label>
76 +
    <input type="text" id="smtp" name="smtp" value="{{ mail_smtp }}" placeholder="smtps://user:[email protected]:465">
77 +
    <label for="mailfrom">From</label>
78 +
    <input type="text" id="mailfrom" name="from" value="{{ mail_from }}" placeholder="OxygenHub &lt;[email protected]&gt;">
68 79
    <button type="submit">SAVE</button>
69 80
  </form>
70 81
  <form class="card" method="post" action="/admin/mail/test"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit"{% if !mail_enabled || my_email.is_empty() %} disabled{% endif %}>SEND A TEST{% if !my_email.is_empty() %} TO {{ my_email }}{% endif %}</button></form>
71 82
  <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} An empty URL turns it off. The password stays on the server and is shown masked.</p>
72 -
</aside>
73 -
</div>
83 +
</section>
74 84
{% call va::panel("/admin", variables, shell.csrf, "Every repository's jobs get these, unless an organisation or repository sets the same name.") %}
75 85
{% call se::panel("/admin", secrets, shell.csrf, "Every repository's jobs may use these, unless an organisation or repository sets the same name.") %}
76 86
{% call ai::panel("/admin", assistants, shell.csrf, "Every repository on the site may ask these, unless an organisation or repository turns one off.") %}
87 +
<section class="panel" id="audit">
88 +
  <h2>Audit log</h2>
89 +
  <div class="table"><table class="small"><tr><th>When</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr>
90 +
  {% for r in audit %}<tr><td><time title="{{ r.at_ms|when_i }}">{{ r.at_ms|ago_i }}</time></td><td>{{ r.actor }}</td><td><code>{{ r.action }}</code></td><td>{{ r.target }}</td><td class="mute">{{ r.detail }}</td></tr>{% endfor %}
91 +
  </table></div>
92 +
  <p class="pager small"><a href="/admin#audit">newest</a>{% if let Some(o) = audit_older %} <a class="go small" href="/admin?audit_before={{ o }}#audit">older</a>{% endif %}</p>
93 +
</section>
94 +
</div>
95 +
</div>
77 96
{% endblock %}