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 |