//! Page: access tokens (`/settings/tokens`). Tokens are shown once, at creation. use std::time::Duration; use forge_core::time::stamp_after; use forge_core::token::{generate_token, hash_token}; use forge_core::{AccessToken, AuditAction}; use http::HeaderValue; use http::header::CACHE_CONTROL; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, see_other}; use topcoat::router::response::response_headers; use topcoat::router::{page, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::{self, Key, Lang, t, token_meta, token_name_length}; use crate::state::AppState; use crate::views::{alert, shell}; /// Scopes are fixed to `repo` for M1. const SCOPES: &[&str] = &["repo"]; const MAX_NAME_CHARS: usize = 64; #[derive(Deserialize)] struct CreateTokenForm { #[serde(default)] csrf: String, #[serde(default)] name: String, #[serde(default)] expires: String, } #[derive(Deserialize)] struct DeleteTokenForm { #[serde(default)] csrf: String, #[serde(default)] id: String, } /// Maps the form's expiry choice to a lifetime. `None` means the token never expires. /// An unrecognised value falls back to the 90-day default. fn expiry_days(choice: &str) -> Option { match choice { "30" => Some(30), "365" => Some(365), "never" => None, _ => Some(90), } } #[component] async fn tokens_view( lang: Lang, csrf: &str, tokens: Vec, created: Option, error: Option, ) -> Result { Ok(view! {

(t(lang, Key::AccessTokens))

if let Some(token) = created {

(t(lang, Key::YourNewToken))

(t(lang, Key::CopyNow))

(token)

(t(lang, Key::UseAsPassword))

}
if tokens.is_empty() {

(t(lang, Key::NoAccessTokens))

} else {
    for token in tokens.into_iter() {
  • (token.name.clone()) (token_meta(lang, token.created_at.as_str(), token.last_used_at.as_deref(), token.expires_at.as_deref()))
  • }
}

(t(lang, Key::NewToken))

if let Some(message) = error { alert(message: message.as_str()) }

(t(lang, Key::ScopeNote))

}) } #[page("/settings/tokens")] async fn tokens_page(cx: &Cx) -> Result { let viewer = require_user(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let tokens = app_context::(cx) .store .list_tokens(&viewer.0.id) .await?; let csrf = viewer.1.csrf_token.clone(); Ok(view! { shell(title: t(lang, Key::AccessTokens), viewer: Some(&viewer), tokens_view(lang: lang, csrf: csrf.as_str(), tokens: tokens, created: None, error: None)) }) } #[page(POST "/settings/tokens")] async fn tokens_create(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let store = &app_context::(cx).store; let name = form.name.trim(); // Some(message) on invalid input; otherwise the new token to show once. let (created, error) = if name.is_empty() || name.chars().count() > MAX_NAME_CHARS { (None, Some(token_name_length(lang, MAX_NAME_CHARS))) } else { let token = generate_token(); let expires_at = expiry_days(&form.expires) .map(|days| stamp_after(Duration::from_secs(days * 24 * 60 * 60))); let scopes: Vec = SCOPES.iter().map(|s| (*s).to_owned()).collect(); let row = store .create_token( &viewer.0.id, name, &hash_token(&token), &scopes, expires_at.as_deref(), ) .await?; audit::record( store, Some(cx), Actor::User(&viewer.0), AuditAction::TokenCreated, Target { kind: "token", id: &row.id, name: &row.name, }, serde_json::Value::Null, ) .await; (Some(token), None) }; // The response may carry the plaintext token, so keep it out of caches. response_headers(cx).append(CACHE_CONTROL, HeaderValue::from_static("no-store")); let tokens = store.list_tokens(&viewer.0.id).await?; let csrf = viewer.1.csrf_token.clone(); Ok(view! { shell(title: t(lang, Key::AccessTokens), viewer: Some(&viewer), tokens_view(lang: lang, csrf: csrf.as_str(), tokens: tokens, created: created, error: error)) }) } #[route(POST "/settings/tokens/delete")] async fn tokens_delete(cx: &Cx, Form(form): Form) -> Result { let (user, _) = require_csrf(cx, &form.csrf).await?; let state = app_context::(cx); if state.store.delete_token(&user.id, &form.id).await? { audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::TokenRevoked, Target { kind: "token", id: &form.id, name: "", }, serde_json::Value::Null, ) .await; } Ok(see_other("/settings/tokens")) } #[cfg(test)] mod tests { use super::*; #[test] fn expiry_choices_map_to_days_or_none() { assert_eq!(expiry_days("30"), Some(30)); assert_eq!(expiry_days("90"), Some(90)); assert_eq!(expiry_days("365"), Some(365)); assert_eq!(expiry_days("never"), None); assert_eq!(expiry_days("7"), Some(90)); } }