OXYGENHUB

Device sign-in: a clear done page after approving or denying 4da8b828a822

The answer page shows Signed in (or Denied) with the app's name, says
there is nothing to copy or enter, links the token in Settings, and
no longer shows the code form again under the notice. Test.

closes #83
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 16:54 UTC
Signature
✔ signed by Andy McCall
Hash
4da8b828a822b183cb76ba74689bd0173c244e19385d8c51ddb65facf7141cb2
Depends on
749d9a5fcd9f b5668108487f b64e2537d702 cb71e18eb6f9 d23464c0ac60 df71c7892d27
Depended on by
8e96499f2705
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.3.1 v0.4.0 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
12 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Device sign-in says it is done (#83).** After Approve, `/device` shows "Signed in: oxy on saturn is now signed in as you; return to it, there is nothing to copy or enter" with a link to its token, instead of the code form again, which read as if the code were needed a second time. Denying shows a matching "Denied" card.
8 +
7 9
- **The issue page's sidebar, laid out as GitHub's (#82).** Assignees, Labels and Milestone each show what is set under their heading (avatars, coloured label pills) with a gear opening a menu: a filterable checklist (people who can be assigned, with avatars; labels with their colour and description; a milestone) that applies in one go. "Assign yourself" when nobody is assigned. Close, Not planned, Lock and Pin are quiet rows with icons at the foot, evenly spaced, and the comment box is as wide as the conversation.
8 10
9 11
- **Disable and delete accounts (#81).** Administration offers Disable and Enable beside Ban: a disabled account is refused like a banned one (sign-in, sessions, tokens, pushes), and signing in says "Your account has been disabled. Contact the hub's administrator." Delete removes an account for good after its username is typed again: its tokens, keys and memberships go, what it wrote stays under its name, and the name stays retired so nobody can take it over. It is refused, naming them, while the account owns repositories or is the only owner of an organisation; nobody disables or deletes themselves or the last admin who can sign in. A deleted account's proposal reviews now stay, as its comments do.
…
37 unchanged lines
modified crates/hub/src/web/device.rs +19 −7
…
18 unchanged lines
19 19
    code: String,
20 20
    /// The pending request the code names, with each scope it asked for.
21 21
    pending: Option<(accounts::DeviceCode, Vec<(&'static str, &'static str)>)>,
22 -
    done: Option<String>,
22 +
    /// The answer just given: the page then says so and shows no code
23 +
    /// form, so nobody thinks the code is needed again (oxygenhub#83).
24 +
    done: Option<Done>,
23 25
    error: Option<String>,
24 26
}
25 27
28 +
pub struct Done {
29 +
    pub approved: bool,
30 +
    /// The app, as it named itself ("oxy on saturn").
31 +
    pub client: String,
32 +
    pub username: String,
33 +
}
34 +
26 35
/// A code still waiting for an answer.
27 36
async fn pending(app: &Shared, code: &str) -> Result<Option<accounts::DeviceCode>, sqlx::Error> {
28 37
    Ok(accounts::device_by_user_code(&app.db, code).await?.filter(|d| d.account_id.is_none() && !d.denied && d.expires_ms >= db::now_ms()))
29 38
}
30 39
31 -
fn page(shell: Shell, code: String, d: Option<accounts::DeviceCode>, done: Option<String>, error: Option<String>) -> Page<DevicePage> {
40 +
fn page(shell: Shell, code: String, d: Option<accounts::DeviceCode>, done: Option<Done>, error: Option<String>) -> Page<DevicePage> {
32 41
    let pending = d.map(|d| {
33 42
        let asked: Vec<(&'static str, &'static str)> = SCOPES.iter().filter(|(k, _)| d.scope_list().contains(k)).copied().collect();
34 43
        (d, asked)
…
59 unchanged lines
94 103
                return Ok(page(shell, code, None, None, Some("That code was answered or expired meanwhile.".into())));
95 104
            }
96 105
            accounts::audit(&app.db, Some(&me.account), "device.approved", &d.client, &chosen.join(",")).await.ok();
97 -
            let done = format!(
98 -
                "Approved: {} is signed in as {} and will get its token in a moment. It is listed under Settings → Tokens, where you can revoke it.",
99 -
                d.client, me.account.username
100 -
            );
106 +
            let done = Done { approved: true, client: d.client.clone(), username: me.account.username.clone() };
101 107
            Ok(page(shell, String::new(), None, Some(done), None))
102 108
        }
103 109
        "deny" => {
104 110
            accounts::deny_device(&app.db, d.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
105 111
            accounts::audit(&app.db, Some(&me.account), "device.denied", &d.client, "").await.ok();
106 -
            Ok(page(shell, String::new(), None, Some(format!("Denied: {} was not signed in.", d.client)), None))
112 +
            Ok(page(
113 +
                shell,
114 +
                String::new(),
115 +
                None,
116 +
                Some(Done { approved: false, client: d.client.clone(), username: me.account.username.clone() }),
117 +
                None,
118 +
            ))
107 119
        }
108 120
        // Looking a typed code up.
109 121
        _ => Ok(page(shell, code, Some(d), None, None)),
…
2 unchanged lines
modified crates/hub/tests/api.rs +3 −1
…
1288 unchanged lines
1289 1289
    let r =
1290 1290
        call(app, "POST", "/device", None, Some(&andy), None, Some(&format!("_csrf={csrf}&code={user}&action=approve&scopes=user%3Aread")))
1291 1291
            .await;
1292 -
    assert!(r.body.contains("Approved"), "{}", r.body);
1292 +
    // oxygenhub#83: the answer page says it is done, with no code form.
1293 +
    assert!(r.body.contains("Signed in") && r.body.contains("nothing to copy or enter"), "{}", r.body);
1294 +
    assert!(!r.body.contains("The code your app shows"), "no code form after approving");
1293 1295
1294 1296
    // The next poll gets a token with what was allowed, once.
1295 1297
    let (st, v) = api(app, "POST", "/api/v1/device/token", None, Some(&poll)).await;
…
33 unchanged lines
modified templates/device.html +13 −2
…
1 unchanged lines
2 2
{% block title %}Sign in a device{% endblock %}
3 3
{% block content %}
4 4
<div class="head"><h1>Sign in a device <small>an app asked to use your account</small></h1></div>
5 -
{% if let Some(d) = done %}<p class="note">{{ d }}</p>{% endif %}
6 5
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
7 -
{% if let Some((d, asked)) = pending %}
6 +
{% if let Some(d) = done %}
7 +
<section class="card wide device-done {% if d.approved %}ok{% else %}no{% endif %}">
8 +
  {% if d.approved %}
9 +
  <h2><span class="device-mark" aria-hidden="true">✓</span> Signed in</h2>
10 +
  <p><strong>{{ d.client }}</strong> is now signed in as <strong>{{ d.username }}</strong>. You can close this page and return to it: it picks up its token by itself, so there is nothing to copy or enter.</p>
11 +
  <p class="small mute">It is listed under <a href="/settings/tokens">Settings → Tokens</a>, where you can revoke it.</p>
12 +
  {% else %}
13 +
  <h2><span class="device-mark" aria-hidden="true">✕</span> Denied</h2>
14 +
  <p><strong>{{ d.client }}</strong> was not signed in, and its code no longer works.</p>
15 +
  {% endif %}
16 +
  <p class="small"><a href="/device">Sign in another device</a></p>
17 +
</section>
18 +
{% else if let Some((d, asked)) = pending %}
8 19
<form class="card wide" method="post" action="/device">
9 20
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
10 21
  <input type="hidden" name="code" value="{{ d.user_code }}">
…
20 unchanged lines
modified templates/style.css +6 −0
…
704 unchanged lines
705 705
button.side-action span { width: 16px; text-align: center; color: var(--mute); }
706 706
button.side-action:hover { color: var(--acc); }
707 707
button.side-action:focus-visible { outline: 2px solid var(--acc); outline-offset: 2px; }
708 +
709 +
/* Device sign-in's answer (oxygenhub#83). */
710 +
.device-done h2 { display: flex; align-items: center; gap: 10px; margin-top: 0; }
711 +
.device-mark { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; font-size: 16px; color: #fff; }
712 +
.device-done.ok .device-mark { background: var(--ok); }
713 +
.device-done.no .device-mark { background: var(--bad); }