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); } |