Device tokens expire and renew on use; the approval page shows the requester (closes #70) 60f11b71a740
A token from device sign-in now expires 90 days after its last use: each use moves its expiry on (tokens.renew_ms), and Settings -> Tokens says so. A device code records the address that asked for it, from the proxy's headers when server.behind_proxy is set and the peer's otherwise; the approval page shows it with when the code was asked for, and warns to approve only a code you started yourself. DELETE /api/v1/user/token revokes the token it is made with, for oxy auth logout.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-01 13:33 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
60f11b71a74091b01385c654b0011014d4036deb49e2576b6d2691242cb260bd- Depends on
- 0b20f1ec4007 19bb31ff1f5c b5668108487f b64e2537d702 d23464c0ac60 df71c7892d27 e9bfea8e26db
- Depended on by
- aeed63c00ad7 5714347a6233 f4e138301198 ecac6e8d9b41 05f1a85a2842 c92a99490cba 5d90e0e045e2 00ef7b83b340 bff9106edcba 2bbe88a85860
- 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.2.0 v0.3.0 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
- 34 atoms, 1 tree operation, 10 files
Browse the tree at this change
added crates/hub/migrations/0030_device_token_life.sql +5 −0
| 1 | + | -- oxygenhub#70: tokens from device sign-in expire, and each use renews |
|
| 2 | + | -- them (renew_ms: the lifetime a use extends the token to); a device code |
|
| 3 | + | -- keeps the address that asked for it, shown when it is approved. |
|
| 4 | + | ALTER TABLE tokens ADD COLUMN renew_ms INTEGER; |
|
| 5 | + | ALTER TABLE device_codes ADD COLUMN requester TEXT NOT NULL DEFAULT ''; |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Device tokens expire, and the approval page says where a request came from (#70).** A token from device sign-in lasts 90 days past its last use; Settings → Tokens shows its expiry, renewed on use. The `/device` page shows when the code was asked for and from which address (the client's, through the proxy when `behind_proxy` is set), and warns to approve only a code you started yourself. `DELETE /api/v1/user/token` revokes the token it is called with, which `oxy auth logout` uses. |
|
| 8 | + | ||
| 7 | 9 | - **Device sign-in (#67).** An app gets a token without a pasted secret. It asks `/api/v1/device/code` for a code and shows it; you approve the code at `/device`, signed in with your second factor, choosing what the app may do; its next poll gets the token. `oh auth login` signs in this way; `--token` still takes a token. |
|
| 8 | 10 | - **Review requests (#68).** The author or a writer asks someone to review under Reviewers on the proposal page, or with `POST .../proposals/{number}/reviewers`. The person asked is notified, and their review answers the request. Every review now tells the author. |
|
| 9 | 11 | - **Runner scopes (#41, part 2).** A runner serves the whole site, one owner's repositories, or one repository, set when its registration token is made or later on Admin → Runners. It never takes another scope's jobs. |
|
| … | 9 unchanged lines |
modified crates/hub/src/api/device.rs +18 −3
| … | 11 unchanged lines |
||
| 12 | 12 | use serde::Deserialize; |
|
| 13 | 13 | use serde_json::json; |
|
| 14 | 14 | ||
| 15 | + | /// How long a device's token lasts after its last use (oxygenhub#70). |
|
| 16 | + | pub const TOKEN_LIFE_MS: i64 = 90 * 24 * 3600 * 1000; |
|
| 15 | 17 | /// How long a code lasts, and how often an app may poll. |
|
| 16 | 18 | const LIFETIME_MS: i64 = 15 * 60 * 1000; |
|
| 17 | 19 | const INTERVAL_S: i64 = 5; |
|
| … | 35 unchanged lines |
||
| 53 | 55 | ||
| 54 | 56 | /// `POST /api/v1/device/code`: a device code to poll with and a user code |
|
| 55 | 57 | /// to show. No token needed. |
|
| 56 | - | pub async fn code(State(app): State<Shared>, Json(req): Json<CodeRequest>) -> Response { |
|
| 58 | + | pub async fn code( |
|
| 59 | + | State(app): State<Shared>, |
|
| 60 | + | headers: axum::http::HeaderMap, |
|
| 61 | + | extensions: axum::http::Extensions, |
|
| 62 | + | Json(req): Json<CodeRequest>, |
|
| 63 | + | ) -> Response { |
|
| 64 | + | let requester = super::client_addr(&app, &headers, &extensions); |
|
| 57 | 65 | let known = |s: &str| crate::web::settings::SCOPES.iter().any(|(k, _)| *k == s); |
|
| 58 | 66 | let mut scopes: Vec<&str> = req.scopes.iter().map(String::as_str).filter(|s| known(s)).collect(); |
|
| 59 | 67 | if scopes.len() != req.scopes.len() { |
|
| … | 10 unchanged lines |
||
| 70 | 78 | // A clash of user codes is unlikely; try a few. |
|
| 71 | 79 | for _ in 0..4 { |
|
| 72 | 80 | let user = user_code(); |
|
| 73 | - | match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires).await { |
|
| 81 | + | match accounts::create_device_code(&app.db, &hash, &user, &client, &scopes.join(","), expires, &requester).await { |
|
| 74 | 82 | Ok(_) => { |
|
| 75 | 83 | let base = app.config.base_url().trim_end_matches('/').to_string(); |
|
| 76 | 84 | return Json(json!({ |
|
| … | 57 unchanged lines |
||
| 134 | 142 | }; |
|
| 135 | 143 | let (secret, token_hash) = crate::auth::new_token(); |
|
| 136 | 144 | let name = format!("device: {}", d.client); |
|
| 137 | - | if let Err(e) = accounts::create_token(&app.db, account_id, &name, &token_hash, &d.approved_scopes, "", None).await { |
|
| 145 | + | // A device's token lasts while it is used: each use renews it. |
|
| 146 | + | let expires = now + TOKEN_LIFE_MS; |
|
| 147 | + | let made = match accounts::create_token(&app.db, account_id, &name, &token_hash, &d.approved_scopes, "", Some(expires)).await { |
|
| 148 | + | Ok(t) => t, |
|
| 149 | + | Err(e) => return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()), |
|
| 150 | + | }; |
|
| 151 | + | if let Err(e) = accounts::set_token_renewal(&app.db, made.id, TOKEN_LIFE_MS).await { |
|
| 138 | 152 | return oauth_error(StatusCode::INTERNAL_SERVER_ERROR, "server_error", &e.to_string()); |
|
| 139 | 153 | } |
|
| 140 | 154 | accounts::audit(&app.db, Some(&account), "token.device", &name, &d.approved_scopes).await.ok(); |
|
| … | 2 unchanged lines |
||
| 143 | 157 | "token_type": "bearer", |
|
| 144 | 158 | "scope": d.approved_scopes.replace(',', " "), |
|
| 145 | 159 | "username": account.username, |
|
| 160 | + | "expires_in": TOKEN_LIFE_MS / 1000, |
|
| 146 | 161 | })) |
|
| 147 | 162 | .into_response() |
|
| 148 | 163 | } |
modified crates/hub/src/api/mod.rs +41 −2
| … | 46 unchanged lines |
||
| 47 | 47 | .route("/device/code", post(device::code)) |
|
| 48 | 48 | .route("/device/token", post(device::token)) |
|
| 49 | 49 | .route("/user", get(me)) |
|
| 50 | + | .route("/user/token", axum::routing::delete(revoke_own_token)) |
|
| 50 | 51 | .route("/version", get(version)) |
|
| 51 | 52 | .route("/notifications", get(notifications)) |
|
| 52 | 53 | .route("/notifications/read", post(notifications_read_all)) |
|
| … | 132 unchanged lines |
||
| 185 | 186 | ApiError(StatusCode::FORBIDDEN, what.into()) |
|
| 186 | 187 | } |
|
| 187 | 188 | ||
| 189 | + | /// The address a request came from: the client's, as the proxy reports |
|
| 190 | + | /// it when the hub is behind one (`server.behind_proxy`), else the peer's. |
|
| 191 | + | /// Only an address's characters are kept, so it is safe to show. |
|
| 192 | + | pub fn client_addr(app: &Shared, headers: &HeaderMap, extensions: &axum::http::Extensions) -> String { |
|
| 193 | + | let from_proxy = || { |
|
| 194 | + | ["cf-connecting-ip", "x-forwarded-for", "x-real-ip"] |
|
| 195 | + | .iter() |
|
| 196 | + | .find_map(|h| headers.get(*h).and_then(|v| v.to_str().ok()).and_then(|v| v.split(',').next()).map(|v| v.trim().to_string())) |
|
| 197 | + | }; |
|
| 198 | + | let addr = if app.config.server.behind_proxy { |
|
| 199 | + | from_proxy() |
|
| 200 | + | } else { |
|
| 201 | + | extensions.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>().map(|c| c.0.ip().to_string()) |
|
| 202 | + | }; |
|
| 203 | + | addr.map(|a| a.chars().filter(|c| c.is_ascii_hexdigit() || matches!(c, '.' | ':')).take(45).collect()).unwrap_or_default() |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | /// `DELETE /api/v1/user/token`: revoke the token this request is made |
|
| 207 | + | /// with, as `oxy auth logout` does. |
|
| 208 | + | async fn revoke_own_token(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> { |
|
| 209 | + | let c = caller(&app, &headers).await?; |
|
| 210 | + | let (Some(account), Some(id)) = (c.account.as_ref(), c.token_id) else { |
|
| 211 | + | return Err(ApiError(StatusCode::UNAUTHORIZED, "revoking needs the personal access token to revoke".into())); |
|
| 212 | + | }; |
|
| 213 | + | db::accounts::delete_token(&app.db, account.id, id).await?; |
|
| 214 | + | db::accounts::audit(&app.db, Some(account), "token.revoke", &c.token, "").await.ok(); |
|
| 215 | + | Ok(StatusCode::NO_CONTENT) |
|
| 216 | + | } |
|
| 217 | + | ||
| 188 | 218 | /// Who calls: the account behind a bearer token, with the token's scopes. |
|
| 189 | 219 | pub struct Caller { |
|
| 190 | 220 | pub account: Option<Account>, |
|
| … | 1 unchanged lines |
||
| 192 | 222 | pub site: bool, |
|
| 193 | 223 | /// The token's name, for records of what a call did. |
|
| 194 | 224 | pub token: String, |
|
| 225 | + | /// The personal access token's id, when one is used. |
|
| 226 | + | pub token_id: Option<i64>, |
|
| 195 | 227 | } |
|
| 196 | 228 | ||
| 197 | 229 | impl Caller { |
|
| … | 16 unchanged lines |
||
| 214 | 246 | ||
| 215 | 247 | pub async fn caller(app: &Shared, headers: &HeaderMap) -> Result<Caller, ApiError> { |
|
| 216 | 248 | let Some(t) = oxy_server::bearer_token(headers) else { |
|
| 217 | - | return Ok(Caller { account: None, scopes: vec![], site: false, token: String::new() }); |
|
| 249 | + | return Ok(Caller { account: None, scopes: vec![], site: false, token: String::new(), token_id: None }); |
|
| 218 | 250 | }; |
|
| 219 | 251 | if app.config.auth.push_tokens.iter().any(|x| auth::mac_equal(x, t)) { |
|
| 220 | 252 | let admin = db::accounts::accounts(&app.db).await?.into_iter().find(|a| a.is_admin); |
|
| … | 2 unchanged lines |
||
| 223 | 255 | scopes: vec!["repo:admin".into(), "user:read".into(), "checks:write".into()], |
|
| 224 | 256 | site: true, |
|
| 225 | 257 | token: "site token".into(), |
|
| 258 | + | token_id: None, |
|
| 226 | 259 | }); |
|
| 227 | 260 | } |
|
| 228 | 261 | let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?; |
|
| … | 1 unchanged lines |
||
| 230 | 263 | Some(tok) if !tok.expired(db::now_ms()) => { |
|
| 231 | 264 | let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned); |
|
| 232 | 265 | db::accounts::touch_token(&app.db, tok.id).await.ok(); |
|
| 233 | - | Ok(Caller { account, scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), site: false, token: tok.name.clone() }) |
|
| 266 | + | Ok(Caller { |
|
| 267 | + | account, |
|
| 268 | + | scopes: tok.scope_list().iter().map(|s| s.to_string()).collect(), |
|
| 269 | + | site: false, |
|
| 270 | + | token: tok.name.clone(), |
|
| 271 | + | token_id: Some(tok.id), |
|
| 272 | + | }) |
|
| 234 | 273 | } |
|
| 235 | 274 | _ => Err(ApiError(StatusCode::UNAUTHORIZED, "unknown or expired token".into())), |
|
| 236 | 275 | } |
|
| … | 1482 unchanged lines |
modified crates/hub/src/db/accounts.rs +23 −2
| … | 75 unchanged lines |
||
| 76 | 76 | pub created_ms: i64, |
|
| 77 | 77 | pub expires_ms: Option<i64>, |
|
| 78 | 78 | pub last_used_ms: Option<i64>, |
|
| 79 | + | /// A renewing token's lifetime: each use extends it to now plus this. |
|
| 80 | + | pub renew_ms: Option<i64>, |
|
| 79 | 81 | } |
|
| 80 | 82 | ||
| 81 | 83 | impl Token { |
|
| … | 379 unchanged lines |
||
| 461 | 463 | Ok(()) |
|
| 462 | 464 | } |
|
| 463 | 465 | ||
| 466 | + | /// Record a use; a renewing token's expiry moves to now plus its lifetime. |
|
| 464 | 467 | pub async fn touch_token(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 465 | - | sqlx::query("UPDATE tokens SET last_used_ms = ? WHERE id = ?").bind(now_ms()).bind(id).execute(pool).await?; |
|
| 468 | + | sqlx::query( |
|
| 469 | + | "UPDATE tokens SET last_used_ms = ?1, \ |
|
| 470 | + | expires_ms = CASE WHEN renew_ms IS NULL THEN expires_ms ELSE MAX(COALESCE(expires_ms, 0), ?1 + renew_ms) END \ |
|
| 471 | + | WHERE id = ?2", |
|
| 472 | + | ) |
|
| 473 | + | .bind(now_ms()) |
|
| 474 | + | .bind(id) |
|
| 475 | + | .execute(pool) |
|
| 476 | + | .await?; |
|
| 477 | + | Ok(()) |
|
| 478 | + | } |
|
| 479 | + | ||
| 480 | + | /// Make a token renewing: it lasts `renew_ms` past its last use. |
|
| 481 | + | pub async fn set_token_renewal(pool: &Pool, id: i64, renew_ms: i64) -> sqlx::Result<()> { |
|
| 482 | + | sqlx::query("UPDATE tokens SET renew_ms = ? WHERE id = ?").bind(renew_ms).bind(id).execute(pool).await?; |
|
| 466 | 483 | Ok(()) |
|
| 467 | 484 | } |
|
| 468 | 485 | ||
| … | 14 unchanged lines |
||
| 483 | 500 | pub account_id: Option<i64>, |
|
| 484 | 501 | pub approved_scopes: String, |
|
| 485 | 502 | pub denied: bool, |
|
| 503 | + | /// The address that asked for the code, as the hub saw it. |
|
| 504 | + | pub requester: String, |
|
| 486 | 505 | } |
|
| 487 | 506 | ||
| 488 | 507 | impl DeviceCode { |
|
| … | 9 unchanged lines |
||
| 498 | 517 | client: &str, |
|
| 499 | 518 | scopes: &str, |
|
| 500 | 519 | expires_ms: i64, |
|
| 520 | + | requester: &str, |
|
| 501 | 521 | ) -> sqlx::Result<DeviceCode> { |
|
| 502 | 522 | // Expired codes go whenever a new one is made. |
|
| 503 | 523 | sqlx::query("DELETE FROM device_codes WHERE expires_ms < ?").bind(now_ms()).execute(pool).await?; |
|
| 504 | 524 | sqlx::query_as( |
|
| 505 | - | "INSERT INTO device_codes (device_hash, user_code, client, scopes, created_ms, expires_ms) VALUES (?, ?, ?, ?, ?, ?) RETURNING *", |
|
| 525 | + | "INSERT INTO device_codes (device_hash, user_code, client, scopes, created_ms, expires_ms, requester) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *", |
|
| 506 | 526 | ) |
|
| 507 | 527 | .bind(device_hash) |
|
| 508 | 528 | .bind(user_code) |
|
| … | 1 unchanged lines |
||
| 510 | 530 | .bind(scopes) |
|
| 511 | 531 | .bind(now_ms()) |
|
| 512 | 532 | .bind(expires_ms) |
|
| 533 | + | .bind(requester) |
|
| 513 | 534 | .fetch_one(pool) |
|
| 514 | 535 | .await |
|
| 515 | 536 | } |
|
| … | 298 unchanged lines |
modified crates/hub/src/lib.rs +9 −2
| … | 84 unchanged lines |
||
| 85 | 85 | let listener = tokio::net::TcpListener::bind(addr).await?; |
|
| 86 | 86 | tracing::info!("http on {addr}"); |
|
| 87 | 87 | let r = router.clone(); |
|
| 88 | - | tasks.spawn(async move { axum::serve(listener, r).await.map_err(anyhow::Error::from) }); |
|
| 88 | + | tasks.spawn(async move { |
|
| 89 | + | axum::serve(listener, r.into_make_service_with_connect_info::<std::net::SocketAddr>()).await.map_err(anyhow::Error::from) |
|
| 90 | + | }); |
|
| 89 | 91 | } |
|
| 90 | 92 | if let Some(addr) = https { |
|
| 91 | 93 | let _ = rustls::crypto::ring::default_provider().install_default(); |
|
| … | 2 unchanged lines |
||
| 94 | 96 | .map_err(|e| anyhow::anyhow!("loading TLS files: {e}"))?; |
|
| 95 | 97 | tracing::info!("https on {addr}"); |
|
| 96 | 98 | let r = router.clone(); |
|
| 97 | - | tasks.spawn(async move { axum_server::bind_rustls(addr, tls).serve(r.into_make_service()).await.map_err(anyhow::Error::from) }); |
|
| 99 | + | tasks.spawn(async move { |
|
| 100 | + | axum_server::bind_rustls(addr, tls) |
|
| 101 | + | .serve(r.into_make_service_with_connect_info::<std::net::SocketAddr>()) |
|
| 102 | + | .await |
|
| 103 | + | .map_err(anyhow::Error::from) |
|
| 104 | + | }); |
|
| 98 | 105 | } |
|
| 99 | 106 | tokio::select! { |
|
| 100 | 107 | r = tasks.join_next() => { r.transpose()?.transpose()?; Ok(()) } |
|
| … | 3 unchanged lines |
modified crates/hub/src/web/device.rs +1 −1
| … | 2 unchanged lines |
||
| 3 | 3 | //! for, and approves it with the scopes they choose, or denies it. |
|
| 4 | 4 | ||
| 5 | 5 | use super::settings::{csrf, require, SCOPES}; |
|
| 6 | - | use super::{Error, Page, Shell}; |
|
| 6 | + | use super::{filters, Error, Page, Shell}; |
|
| 7 | 7 | use crate::api::device::normalize_user_code; |
|
| 8 | 8 | use crate::auth::Viewer; |
|
| 9 | 9 | use crate::db::{self, accounts}; |
|
| … | 102 unchanged lines |
modified crates/hub/tests/api.rs +10 −0
| … | 1217 unchanged lines |
||
| 1218 | 1218 | assert_eq!(v["error"], "expired_token", "a code gives one token"); |
|
| 1219 | 1219 | let tokens = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await; |
|
| 1220 | 1220 | assert!(tokens.body.contains("device: Phone app")); |
|
| 1221 | + | // oxygenhub#70: a device's token expires, and each use renews it. |
|
| 1222 | + | assert!(tokens.body.contains("renewed on use"), "{}", tokens.body); |
|
| 1221 | 1223 | ||
| 1224 | + | // The token can revoke itself (oxy auth logout), and is then refused. |
|
| 1225 | + | let (st, _) = api(app, "DELETE", "/api/v1/user/token", Some(&token), None).await; |
|
| 1226 | + | assert_eq!(st, StatusCode::NO_CONTENT); |
|
| 1227 | + | let (st, _) = api(app, "GET", "/api/v1/user", Some(&token), None).await; |
|
| 1228 | + | assert_eq!(st, StatusCode::UNAUTHORIZED); |
|
| 1229 | + | let (st, _) = api(app, "DELETE", "/api/v1/user/token", None, None).await; |
|
| 1230 | + | assert_eq!(st, StatusCode::UNAUTHORIZED, "revoking needs a token"); |
|
| 1231 | + | ||
| 1222 | 1232 | // A denied code says so. |
|
| 1223 | 1233 | let (_, v) = api(app, "POST", "/api/v1/device/code", None, Some(r#"{"client":"Other"}"#)).await; |
|
| 1224 | 1234 | let (device, user) = (v["device_code"].as_str().unwrap().to_string(), v["user_code"].as_str().unwrap().to_string()); |
|
| … | 10 unchanged lines |
modified templates/device.html +3 −1
| … | 8 unchanged lines |
||
| 9 | 9 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 10 | 10 | <input type="hidden" name="code" value="{{ d.user_code }}"> |
|
| 11 | 11 | <h2><strong>{{ d.client }}</strong> wants to sign in as you</h2> |
|
| 12 | - | <p class="small">Code <code>{{ d.user_code }}</code>. Approve only if this is the code your app shows. It gets a token with what you allow here; you can revoke it under Settings → Tokens.</p> |
|
| 12 | + | <p class="small">Code <code>{{ d.user_code }}</code>, asked for <time title="{{ d.created_ms|when_i }}">{{ d.created_ms|ago_i }}</time>{% if !d.requester.is_empty() %} from <code>{{ d.requester }}</code>{% endif %}.</p> |
|
| 13 | + | <p class="warn small">Approve only a code you started yourself, just now, on your own device. If someone sent you this code or link, deny it: they are asking for access to your account.</p> |
|
| 14 | + | <p class="small">The app gets a token with what you allow here. It expires after 90 days without use, and you can revoke it under Settings → Tokens.</p> |
|
| 13 | 15 | <label>Allow it to</label> |
|
| 14 | 16 | <div class="scopes"> |
|
| 15 | 17 | {% for (name, what) in asked %}<label><input type="checkbox" name="scopes" value="{{ name }}" checked> <code>{{ name }}</code> <span class="small mute">{{ what }}</span></label>{% endfor %} |
|
| … | 13 unchanged lines |
modified templates/settings_tokens.html +1 −1
| … | 16 unchanged lines |
||
| 17 | 17 | <tr><td>{{ t.name }}</td><td>{{ t.scopes }}</td><td>{% if t.repos.is_empty() %}<span class="mute">all</span>{% else %}{{ t.repos }}{% endif %}</td> |
|
| 18 | 18 | <td><time title="{{ t.created_ms|when_i }}">{{ t.created_ms|ago_i }}</time></td> |
|
| 19 | 19 | <td>{% if let Some(u) = t.last_used_ms %}<time title="{{ u|when_i }}">{{ u|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td> |
|
| 20 | - | <td>{% if let Some(x) = t.expires_ms %}{{ x|when_i }}{% else %}<span class="mute">never</span>{% endif %}</td> |
|
| 20 | + | <td>{% if let Some(x) = t.expires_ms %}{{ x|when_i }}{% if t.renew_ms.is_some() %} <span class="mute small">renewed on use</span>{% endif %}{% else %}<span class="mute">never</span>{% endif %}</td> |
|
| 21 | 21 | <td><form class="tiny" method="post" action="/settings/tokens/{{ t.id }}/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>delete</button></form></td></tr> |
|
| 22 | 22 | {% endfor %} |
|
| 23 | 23 | </table></div> |
|
| … | 14 unchanged lines |