OXYGENHUB

Settings › Security balanced: every box the page's width (closes #285) 8597fb5ca16c

Two-factor shows whether it is on in its head, with its field and button
on one row; connected accounts are rows with Connect or Disconnect;
signed-in browsers are rows with Sign out, and Sign out everywhere else
sits in that box's head with the session limits under it.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key a91d8da8459e
When
· 2026-10-20 22:39 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
8597fb5ca16c06c328fcb45858cd4e579f621fc29f1abf2e338edc4a28ab75e2
Depends on
0024ccb1b050 00ef7b83b340 0b20f1ec4007 19bb31ff1f5c 367f34b8e590 83940d53664f a7e6e8e6094c b5668108487f d23464c0ac60
Depended on by
a22e4aec2e28 b349e15a3eab
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
11 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Settings › Security is balanced (#285):** every box runs the page's width: two-factor shows whether it is on in its head, with its field and button on one row; connected accounts are rows with Connect or Disconnect; signed-in browsers are rows with Sign out, and Sign out everywhere else sits in that box's head with the session limits under it.
7 8
- **Push without starting a run (#284):** `[skip flow]`, `[skip ci]` or `[ci skip]` in the newest pushed change's message starts no jobs; the run list shows a skipped run naming the change and who pushed it, and the next run still tests what it brought. Tags always run. Maintainers choose where it is allowed under Settings › Runs: any bookmark (the default), any but the default bookmark, or never.
8 9
- **A drop's page is centred and its header clearer (#283):** the page ran the full width with everything against the left edge; it is now centred like other pages, with the title, description and details on the left, EDIT and DELETE together on the right (DELETE only for whoever may delete it), the clone line with a copy button under them, and a Markdown file filling its box.
9 10
- **A started job no longer says it is waiting (#282):** a run page opened while a job was queued kept "Waiting for a runner and for build." under it after it started, as the live update changed its status and streamed its log; the note now goes once the job leaves the queue.
…
267 unchanged lines
modified crates/hub/tests/accounts.rs +9 −5
…
1225 unchanged lines
1226 1226
    let sec = get(app, "/settings/security", Some(&as_andy)).await.body;
1227 1227
    assert!(
1228 1228
        sec.contains("Connected accounts")
1229 -
            && sec.contains("Google: <strong>andy</strong>")
1230 -
            && sec.contains("GitHub: <a href=\"/settings/security/connect/github\">connect</a>"),
1229 +
            && sec.contains("<strong>Google</strong> <span class=\"mute\">andy</span>")
1230 +
            && sec.contains("<strong>GitHub</strong> <span class=\"small mute\">not connected</span>")
1231 +
            && sec.contains("href=\"/settings/security/connect/github\">Connect</a>"),
1231 1232
        "{sec}"
1232 1233
    );
1233 1234
    assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 3, "no fourth account");
…
1 unchanged lines
1235 1236
    let csrf_s = csrf_of(&sec);
1236 1237
    let r = post(app, "/settings/security/disconnect/google", Some(&as_andy), &format!("_csrf={csrf_s}")).await;
1237 1238
    assert!(r.location.as_deref().unwrap_or("").contains("notice=Google%20disconnected"), "{:?}", r.location);
1238 -
    assert!(!get(app, "/settings/security", Some(&as_andy)).await.body.contains("Google: <strong>andy</strong>"));
1239 +
    assert!(!get(app, "/settings/security", Some(&as_andy))
1240 +
        .await
1241 +
        .body
1242 +
        .contains("<strong>Google</strong> <span class=\"mute\">andy</span>"));
1239 1243
    let r = get(app, "/settings/security/connect/google", Some(&as_andy)).await;
1240 1244
    assert_eq!(r.status, StatusCode::SEE_OTHER);
1241 1245
    let (cookie, state) = state_of(&r);
…
372 unchanged lines
1614 1618
    let page = get(app, "/settings/security", Some(&a)).await.body;
1615 1619
    assert_eq!(page.matches("/end\"").count(), 2, "both browsers listed");
1616 1620
    // Each entry is an <li>; the other browser's is the one without the tag.
1617 -
    let entries: Vec<&str> = page.split("<li><strong>").skip(1).filter(|e| e.contains("/end\"")).collect();
1621 +
    let entries: Vec<&str> = page.split("<div class=\"token-name\"><strong>").skip(1).filter(|e| e.contains("/end\"")).collect();
1618 1622
    assert_eq!(entries.iter().filter(|e| e.contains("<span class=\"tag\">this browser</span>")).count(), 1);
1619 1623
    let other = entries.iter().find(|e| !e.contains("class=\"tag\">this browser")).unwrap();
1620 1624
    let other: i64 = other.split("/settings/security/sessions/").nth(1).unwrap().split('/').next().unwrap().parse().unwrap();
1621 1625
    let csrf = csrf_of(&page);
1622 1626
    let r = post(app, &format!("/settings/security/sessions/{other}/end"), Some(&a), &format!("_csrf={csrf}")).await;
1623 1627
    assert_eq!(r.status, StatusCode::SEE_OTHER);
1624 -
    let signed_in = |c: String| async move { get(app, "/settings/security", Some(&c)).await.body.contains("SIGN OUT EVERYWHERE") };
1628 +
    let signed_in = |c: String| async move { get(app, "/settings/security", Some(&c)).await.body.contains("Sign out everywhere else") };
1625 1629
    assert!(signed_in(a.clone()).await, "this browser stays");
1626 1630
    assert!(!signed_in(b.clone()).await, "that browser is signed out");
1627 1631
…
125 unchanged lines
modified templates/settings_security.html +45 −37
…
8 unchanged lines
9 9
{% if !recovery.is_empty() %}
10 10
<div class="panel"><h2>Recovery codes</h2><ul class="codes">{% for c in recovery %}<li>{{ c }}</li>{% endfor %}</ul></div>
11 11
{% endif %}
12 -
{% if let Some((secret, uri, qr)) = enrol %}
13 -
<form class="card" method="post" action="/settings/security/totp/enable">
14 -
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
15 -
  <input type="hidden" name="secret" value="{{ secret }}">
16 -
  <h2>Scan, then enter the code</h2>
17 -
  <div class="qr">{{ qr|safe }}</div>
18 -
  <p class="small">Or enter the secret by hand: <code>{{ secret }}</code></p>
19 -
  <label for="code">Code from the app</label><input type="text" id="code" name="code" inputmode="numeric" autocomplete="one-time-code" autofocus required>
20 -
  <button type="submit">TURN ON</button>
21 -
</form>
22 -
{% else if me.totp_enabled() %}
23 -
<form class="card" method="post" action="/settings/security/totp/disable">
24 -
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
25 -
  <h2><span class="sig sig-valid">✔</span> Two-factor authentication is on</h2>
26 -
  <label for="password">Your password, to turn it off</label><input type="password" id="password" name="password" autocomplete="current-password" required>
27 -
  <button type="submit">TURN OFF</button>
12 +
<section class="card-box" id="two-factor">
13 +
  <div class="card-head"><h2>Two-factor authentication</h2>{% if enrol.is_some() %}<span class="small mute more">setting up</span>{% else if me.totp_enabled() %}<span class="tag ok more"><span class="sig sig-valid">✔</span> on</span>{% else %}<span class="tag more"><span class="sig sig-unsigned">○</span> off</span>{% endif %}</div>
14 +
  {% if let Some((secret, uri, qr)) = enrol %}
15 +
  <form class="pad sec-form" method="post" action="/settings/security/totp/enable">
16 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
17 +
    <input type="hidden" name="secret" value="{{ secret }}">
18 +
    <p>Scan this with your authenticator app, then enter the code it shows.</p>
19 +
    <div class="qr">{{ qr|safe }}</div>
20 +
    <p class="small mute">Or enter the secret by hand: <code>{{ secret }}</code></p>
21 +
    <div class="sec-row"><label for="code">Code from the app</label><input type="text" id="code" name="code" inputmode="numeric" autocomplete="one-time-code" autofocus required><button type="submit">TURN ON</button></div>
22 +
  </form>
23 +
  {% else if me.totp_enabled() %}
24 +
  <form class="pad sec-form" method="post" action="/settings/security/totp/disable">
25 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
26 +
    <p class="small mute">A code from your authenticator app is asked for at every sign in.</p>
27 +
    <div class="sec-row"><label for="password">Your password, to turn it off</label><input type="password" id="password" name="password" autocomplete="current-password" required><button type="submit" class="danger">TURN OFF</button></div>
28 28
  </form>
29 29
  {% else %}
30 -
<form class="card" method="post" action="/settings/security/totp/begin">
30 +
  <form class="pad sec-form sec-split" method="post" action="/settings/security/totp/begin">
31 31
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
32 -
  <h2><span class="sig sig-unsigned">○</span> Two-factor authentication is off</h2>
33 -
  <p class="small">A time-based code from an authenticator app is asked for at every sign in.</p>
34 -
  <button type="submit">SET UP</button>
35 -
</form>
36 -
{% endif %}
37 -
{% if !connected.is_empty() %}<section class="panel" id="connected">
38 -
  <h2>Connected accounts</h2>
39 -
  <p class="small">Sign in with these instead of a password.</p>
40 -
  <ul class="plain">{% for (id, name, login) in connected %}<li>{{ name }}: {% if let Some(l) = login %}<strong>{{ l }}</strong> <form class="tiny inline" method="post" action="/settings/security/disconnect/{{ id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">disconnect</button></form>{% else %}<a href="/settings/security/connect/{{ id }}">connect</a>{% endif %}</li>{% endfor %}</ul>
41 -
</section>{% endif %}
42 -
{% if !sessions.is_empty() %}<section class="panel" id="sessions">
43 -
  <h2>Signed in</h2>
44 -
  <ul class="plain sessions">{% for x in sessions %}<li><strong>{{ x.browser }}</strong>{% if x.current %} <span class="tag">this browser</span>{% endif %}<span class="small mute">{% if !x.address.is_empty() %} · {{ x.address }}{% endif %} · used {{ x.seen_ms|ago_i }} · signed in {{ x.created_ms|ago_i }}</span>
45 -
    <form class="tiny inline" method="post" action="/settings/security/sessions/{{ x.id }}/end"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">sign out</button></form></li>{% endfor %}</ul>
32 +
    <p class="small mute">With it on, a time-based code from an authenticator app is asked for at every sign in.</p>
33 +
    <button type="submit">SET UP</button>
34 +
  </form>
35 +
  {% endif %}
36 +
</section>
37 +
{% if !connected.is_empty() %}<section class="card-box" id="connected">
38 +
  <div class="card-head"><h2>Connected accounts</h2><span class="small mute more">sign in with these instead of a password</span></div>
39 +
  <ul class="rows token-rows">{% for (id, name, login) in connected %}
40 +
    <li>
41 +
      <div class="token-main"><div class="token-name"><strong>{{ name }}</strong>{% if let Some(l) = login %} <span class="mute">{{ l }}</span>{% else %} <span class="small mute">not connected</span>{% endif %}</div></div>
42 +
      <div class="token-actions">{% if login.is_some() %}<form class="inline" method="post" action="/settings/security/disconnect/{{ id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="small">Disconnect</button></form>{% else %}<a class="go small" href="/settings/security/connect/{{ id }}">Connect</a>{% endif %}</div>
43 +
    </li>{% endfor %}
44 +
  </ul>
46 45
</section>{% endif %}
47 -
<form class="card" method="post" action="/settings/security/sessions">
48 -
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
49 -
  <h2>Sessions</h2>
50 -
  <p class="small">Sign out every browser except this one. A session ends by itself after 14 days unused, or 30 days after signing in.</p>
51 -
  <button type="submit">SIGN OUT EVERYWHERE</button>
52 -
</form>
46 +
<section class="card-box" id="sessions">
47 +
  <div class="card-head"><h2>Signed in</h2><span class="count-chip">{{ sessions.len() }}</span>
48 +
    <form class="inline more" method="post" action="/settings/security/sessions"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="small" title="Sign out every browser except this one">Sign out everywhere else</button></form></div>
49 +
  {% if sessions.is_empty() %}<p class="empty small">No sessions.</p>{% else %}
50 +
  <ul class="rows token-rows">{% for x in sessions %}
51 +
    <li>
52 +
      <div class="token-main">
53 +
        <div class="token-name"><strong>{{ x.browser }}</strong>{% if x.current %} <span class="tag">this browser</span>{% endif %}</div>
54 +
        <div class="small mute">{% if !x.address.is_empty() %}{{ x.address }} · {% endif %}used <time title="{{ x.seen_ms|when_i }}">{{ x.seen_ms|ago_i }}</time> · signed in <time title="{{ x.created_ms|when_i }}">{{ x.created_ms|ago_i }}</time></div>
55 +
      </div>
56 +
      <div class="token-actions"><form class="inline" method="post" action="/settings/security/sessions/{{ x.id }}/end"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="small">Sign out</button></form></div>
57 +
    </li>{% endfor %}
58 +
  </ul>{% endif %}
59 +
  <p class="small mute pad sec-foot">A session ends by itself after 14 days unused, or 30 days after signing in.</p>
60 +
</section>
53 61
{% endblock %}
modified templates/style.css +11 −0
…
136 unchanged lines
137 137
.tag { display: inline-block; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--acc); background: var(--soft); padding: 1px 6px; vertical-align: middle; }
138 138
.tag.bad { color: var(--bad); }
139 139
.tag.warn { color: var(--warn); }
140 +
.tag.ok { color: var(--ok); }
140 141
.warn-text { color: var(--warn); }
141 142
.tag.head { color: var(--now-ink); background: var(--now); }
142 143
.chip { font-family: "IBM Plex Mono", ui-monospace, monospace; font-size: 12px; background: var(--soft); padding: 1px 6px; border: 1px solid var(--line); }
…
1137 unchanged lines
1280 1281
.token-actions { display: flex; gap: 6px; flex-shrink: 0; }
1281 1282
button.danger { color: var(--bad); }
1282 1283
.agent-pill { font-size: 11px; padding: 1px 7px; border-radius: 10px; background: color-mix(in srgb, var(--acc) 18%, transparent); color: var(--ink); }
1284 +
/* Settings › Security (#285): every box the page's width, a form's
1285 +
   field and button on one row. */
1286 +
.sec-form p { margin: 0 0 10px; }
1287 +
.sec-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
1288 +
.sec-row label { margin: 0; font-weight: 600; }
1289 +
.sec-row input { flex: 0 1 320px; min-width: 0; }
1290 +
.sec-split { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1291 +
.sec-split p { margin: 0; }
1292 +
.card-box > .card-head form.more { margin-left: auto; }
1293 +
.card-box > .sec-foot { margin: 0; border-top: 1px solid var(--line); }
1283 1294
.token-form { display: flex; flex-direction: column; gap: 6px; max-width: 720px; }
1284 1295
.token-form .scopes { display: flex; flex-direction: column; gap: 2px; }
1285 1296
.token-form button[type=submit] { align-self: flex-start; margin-top: 8px; }
…
129 unchanged lines