forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.8 KiB
1//! Page: access tokens (`/settings/tokens`). Tokens are shown once, at creation.
2
3use std::time::Duration;
4
5use forge_core::time::stamp_after;
6use forge_core::token::{generate_token, hash_token};
7use forge_core::{AccessToken, AuditAction};
8use http::HeaderValue;
9use http::header::CACHE_CONTROL;
10use serde::Deserialize;
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::Form;
14use topcoat::router::error::{SeeOther, see_other};
15use topcoat::router::response::response_headers;
16use topcoat::router::{page, route};
17use topcoat::view::{View, component, view};
18
19use crate::audit::{self, Actor, Target};
20use crate::auth::{require_csrf, require_user};
21use crate::i18n::{self, Key, Lang, t, token_meta, token_name_length};
22use crate::state::AppState;
23use crate::views::{alert, shell};
24
25/// Scopes are fixed to `repo` for M1.
26const SCOPES: &[&str] = &["repo"];
27const MAX_NAME_CHARS: usize = 64;
28
29#[derive(Deserialize)]
30struct CreateTokenForm {
31 #[serde(default)]
32 csrf: String,
33 #[serde(default)]
34 name: String,
35 #[serde(default)]
36 expires: String,
37}
38
39#[derive(Deserialize)]
40struct DeleteTokenForm {
41 #[serde(default)]
42 csrf: String,
43 #[serde(default)]
44 id: String,
45}
46
47/// Maps the form's expiry choice to a lifetime. `None` means the token never expires.
48/// An unrecognised value falls back to the 90-day default.
49fn expiry_days(choice: &str) -> Option<u64> {
50 match choice {
51 "30" => Some(30),
52 "365" => Some(365),
53 "never" => None,
54 _ => Some(90),
55 }
56}
57
58#[component]
59async fn tokens_view(
60 lang: Lang,
61 csrf: &str,
62 tokens: Vec<AccessToken>,
63 created: Option<String>,
64 error: Option<String>,
65) -> Result<impl View> {
66 Ok(view! {
67 <section class="page-head">
68 <h1>(t(lang, Key::AccessTokens))</h1>
69 </section>
70 if let Some(token) = created {
71 <section class="card notice" role="status">
72 <h2>(t(lang, Key::YourNewToken))</h2>
73 <p>(t(lang, Key::CopyNow))</p>
74 <pre class="code"><code>(token)</code></pre>
75 <p class="muted small">(t(lang, Key::UseAsPassword))</p>
76 </section>
77 }
78 <section class="card">
79 if tokens.is_empty() {
80 <p class="muted">(t(lang, Key::NoAccessTokens))</p>
81 } else {
82 <ul class="item-list">
83 for token in tokens.into_iter() {
84 <li class="item">
85 <div class="item-main">
86 <strong>(token.name.clone())</strong>
87 <span class="muted small">(token_meta(lang, token.created_at.as_str(), token.last_used_at.as_deref(), token.expires_at.as_deref()))</span>
88 </div>
89 <form method="post" action="/settings/tokens/delete">
90 <input type="hidden" name="csrf" value=(csrf)>
91 <input type="hidden" name="id" value=(token.id.clone())>
92 <button type="submit" class="danger">(t(lang, Key::Delete))</button>
93 </form>
94 </li>
95 }
96 </ul>
97 }
98 </section>
99 <section class="card">
100 <h2>(t(lang, Key::NewToken))</h2>
101 if let Some(message) = error {
102 alert(message: message.as_str())
103 }
104 <form method="post" action="/settings/tokens" class="stack">
105 <input type="hidden" name="csrf" value=(csrf)>
106 <label>
107 <span>(t(lang, Key::Name))</span>
108 <input type="text" name="name" maxlength="64" required=(true) autocomplete="off">
109 </label>
110 <label>
111 <span>(t(lang, Key::Expires))</span>
112 <select name="expires">
113 <option value="30">(t(lang, Key::ExpiresIn30))</option>
114 <option value="90" selected=(true)>(t(lang, Key::ExpiresIn90))</option>
115 <option value="365">(t(lang, Key::ExpiresIn365))</option>
116 <option value="never">(t(lang, Key::ExpiresNever))</option>
117 </select>
118 </label>
119 <p class="muted small">(t(lang, Key::ScopeNote))</p>
120 <button type="submit" class="primary">(t(lang, Key::CreateToken))</button>
121 </form>
122 </section>
123 })
124}
125
126#[page("/settings/tokens")]
127async fn tokens_page(cx: &Cx) -> Result<impl View> {
128 let viewer = require_user(cx).await?;
129 let lang = i18n::lang(cx, Some(&viewer.0));
130 let tokens = app_context::<AppState>(cx)
131 .store
132 .list_tokens(&viewer.0.id)
133 .await?;
134 let csrf = viewer.1.csrf_token.clone();
135 Ok(view! {
136 shell(title: t(lang, Key::AccessTokens), viewer: Some(&viewer), tokens_view(lang: lang, csrf: csrf.as_str(), tokens: tokens, created: None, error: None))
137 })
138}
139
140#[page(POST "/settings/tokens")]
141async fn tokens_create(cx: &Cx, Form(form): Form<CreateTokenForm>) -> Result<impl View> {
142 let viewer = require_csrf(cx, &form.csrf).await?;
143 let lang = i18n::lang(cx, Some(&viewer.0));
144 let store = &app_context::<AppState>(cx).store;
145 let name = form.name.trim();
146
147 // Some(message) on invalid input; otherwise the new token to show once.
148 let (created, error) = if name.is_empty() || name.chars().count() > MAX_NAME_CHARS {
149 (None, Some(token_name_length(lang, MAX_NAME_CHARS)))
150 } else {
151 let token = generate_token();
152 let expires_at = expiry_days(&form.expires)
153 .map(|days| stamp_after(Duration::from_secs(days * 24 * 60 * 60)));
154 let scopes: Vec<String> = SCOPES.iter().map(|s| (*s).to_owned()).collect();
155 let row = store
156 .create_token(
157 &viewer.0.id,
158 name,
159 &hash_token(&token),
160 &scopes,
161 expires_at.as_deref(),
162 )
163 .await?;
164 audit::record(
165 store,
166 Some(cx),
167 Actor::User(&viewer.0),
168 AuditAction::TokenCreated,
169 Target {
170 kind: "token",
171 id: &row.id,
172 name: &row.name,
173 },
174 serde_json::Value::Null,
175 )
176 .await;
177 (Some(token), None)
178 };
179
180 // The response may carry the plaintext token, so keep it out of caches.
181 response_headers(cx).append(CACHE_CONTROL, HeaderValue::from_static("no-store"));
182 let tokens = store.list_tokens(&viewer.0.id).await?;
183 let csrf = viewer.1.csrf_token.clone();
184 Ok(view! {
185 shell(title: t(lang, Key::AccessTokens), viewer: Some(&viewer), tokens_view(lang: lang, csrf: csrf.as_str(), tokens: tokens, created: created, error: error))
186 })
187}
188
189#[route(POST "/settings/tokens/delete")]
190async fn tokens_delete(cx: &Cx, Form(form): Form<DeleteTokenForm>) -> Result<SeeOther> {
191 let (user, _) = require_csrf(cx, &form.csrf).await?;
192 let state = app_context::<AppState>(cx);
193 if state.store.delete_token(&user.id, &form.id).await? {
194 audit::record(
195 &state.store,
196 Some(cx),
197 Actor::User(&user),
198 AuditAction::TokenRevoked,
199 Target {
200 kind: "token",
201 id: &form.id,
202 name: "",
203 },
204 serde_json::Value::Null,
205 )
206 .await;
207 }
208 Ok(see_other("/settings/tokens"))
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn expiry_choices_map_to_days_or_none() {
217 assert_eq!(expiry_days("30"), Some(30));
218 assert_eq!(expiry_days("90"), Some(90));
219 assert_eq!(expiry_days("365"), Some(365));
220 assert_eq!(expiry_days("never"), None);
221 assert_eq!(expiry_days("7"), Some(90));
222 }
223}