OXYGENHUB
# c40c2d3edb51 oxygenhub / crates / hub / src / web / settings.rs
20.1 KiB · Rust · last changed Theme switch, auto theme, and a highlighting fix 1 month ago source blame history raw
19bb31ff Andy McCall ↶ 1
//! Account settings (hub spec §6.3 `/settings/*`): profile, security,
2
//! linked identities, tokens.
3
4
use super::{filters, Error, Page, Shell};
5
use crate::auth::{self, totp, Viewer, ViewerAccount};
6
use crate::db::{self, accounts};
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Multipart, Path, State};
c40c2d3e Andy McCall ↶ 10
use axum::http::{header, HeaderMap, StatusCode};
19bb31ff Andy McCall ↶ 11
use axum::response::{IntoResponse, Response};
12
use oxy_core::identity::{Identity, Signature};
13
use serde::Deserialize;
14
15
/// The signed-in account, or a redirect to sign in.
16
pub fn require(viewer: &Viewer, shell: &Shell) -> Result<ViewerAccount, Error> {
17
    viewer.0.clone().ok_or_else(|| Error::redirect(format!("/login?next={}", urlencode(&shell.path))))
18
}
19
20
pub fn urlencode(s: &str) -> String {
21
    let mut out = String::new();
22
    for b in s.bytes() {
23
        match b {
24
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => out.push(b as char),
25
            _ => out.push_str(&format!("%{b:02X}")),
26
        }
27
    }
28
    out
29
}
30
31
pub fn csrf(viewer: &Viewer, token: &str, shell: &Shell) -> Result<(), Error> {
32
    if viewer.csrf_ok(token) {
33
        Ok(())
34
    } else {
35
        Err(Error::bad(shell.clone(), "This form has expired; reload the page and try again."))
36
    }
37
}
38
39
#[derive(Template)]
40
#[template(path = "settings.html")]
41
pub struct SettingsPage {
42
    shell: Shell,
43
    me: accounts::Account,
44
    notice: Option<String>,
45
    error: Option<String>,
c40c2d3e Andy McCall ↶ 46
    /// The theme cookie's value, for the Appearance form.
47
    theme: &'static str,
48
    themes: &'static [(&'static str, &'static str)],
49
}
50
51
/// The themes (hub spec §6.1): the cookie value and its label.
52
const THEMES: &[(&str, &str)] = &[("dark", "Dark"), ("light", "Light"), ("auto", "Match the system")];
53
54
/// The theme the request's cookie asks for; dark when unset or unknown.
55
fn theme_of(headers: &HeaderMap) -> &'static str {
56
    let v = auth::viewer::cookie(headers, "theme").unwrap_or_default();
57
    THEMES.iter().map(|(t, _)| *t).find(|t| *t == v).unwrap_or("dark")
58
}
59
60
impl SettingsPage {
61
    fn new(shell: Shell, me: accounts::Account, headers: &HeaderMap) -> SettingsPage {
62
        SettingsPage { shell, me, notice: None, error: None, theme: theme_of(headers), themes: THEMES }
63
    }
64
65
    fn notice(mut self, n: impl Into<String>) -> Self {
66
        self.notice = Some(n.into());
67
        self
68
    }
69
70
    fn error(mut self, e: impl Into<String>) -> Self {
71
        self.error = Some(e.into());
72
        self
73
    }
19bb31ff Andy McCall ↶ 74
}
75
c40c2d3e Andy McCall ↶ 76
pub async fn profile(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap) -> Result<Page<SettingsPage>, Error> {
19bb31ff Andy McCall ↶ 77
    let shell = Shell::new(&app, "/settings", &viewer);
78
    let me = require(&viewer, &shell)?;
c40c2d3e Andy McCall ↶ 79
    Ok(Page(SettingsPage::new(shell, me.account, &headers)))
19bb31ff Andy McCall ↶ 80
}
81
82
#[derive(Deserialize)]
c40c2d3e Andy McCall ↶ 83
pub struct AppearanceForm {
84
    #[serde(rename = "_csrf")]
85
    csrf_token: String,
86
    theme: String,
87
}
88
89
/// Remember the theme in a cookie for a year; the page script applies it.
90
pub async fn appearance(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AppearanceForm>) -> Result<Response, Error> {
91
    let shell = Shell::new(&app, "/settings", &viewer);
92
    require(&viewer, &shell)?;
93
    csrf(&viewer, &f.csrf_token, &shell)?;
94
    let Some((theme, _)) = THEMES.iter().find(|(t, _)| *t == f.theme) else {
95
        return Err(Error::bad(shell, "Pick dark, light or match the system."));
96
    };
97
    Ok((
98
        StatusCode::SEE_OTHER,
99
        [
100
            (header::SET_COOKIE, format!("theme={theme}; Path=/; Max-Age=31536000; SameSite=Lax")),
101
            (header::LOCATION, "/settings".to_string()),
102
        ],
103
    )
104
        .into_response())
105
}
106
107
#[derive(Deserialize)]
19bb31ff Andy McCall ↶ 108
pub struct ProfileForm {
109
    #[serde(rename = "_csrf")]
110
    csrf_token: String,
111
    display_name: String,
112
    email: String,
113
}
114
c40c2d3e Andy McCall ↶ 115
pub async fn update_profile(
116
    State(app): State<Shared>,
117
    viewer: Viewer,
118
    headers: HeaderMap,
119
    Form(f): Form<ProfileForm>,
120
) -> Result<Page<SettingsPage>, Error> {
19bb31ff Andy McCall ↶ 121
    let shell = Shell::new(&app, "/settings", &viewer);
122
    let me = require(&viewer, &shell)?;
123
    csrf(&viewer, &f.csrf_token, &shell)?;
124
    accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim())
125
        .await
126
        .map_err(|e| Error::from_db(shell.clone(), e))?;
127
    let updated =
128
        accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
c40c2d3e Andy McCall ↶ 129
    Ok(Page(SettingsPage::new(shell, updated, &headers).notice("Profile saved.")))
19bb31ff Andy McCall ↶ 130
}
131
132
#[derive(Deserialize)]
133
pub struct PasswordForm {
134
    #[serde(rename = "_csrf")]
135
    csrf_token: String,
136
    current: String,
137
    password: String,
138
}
139
c40c2d3e Andy McCall ↶ 140
pub async fn change_password(
141
    State(app): State<Shared>,
142
    viewer: Viewer,
143
    headers: HeaderMap,
144
    Form(f): Form<PasswordForm>,
145
) -> Result<Response, Error> {
19bb31ff Andy McCall ↶ 146
    let shell = Shell::new(&app, "/settings", &viewer);
147
    let me = require(&viewer, &shell)?;
148
    csrf(&viewer, &f.csrf_token, &shell)?;
149
    if !auth::verify_password(&me.account.password_hash, &f.current) {
c40c2d3e Andy McCall ↶ 150
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).error("Your current password is wrong.")).into_response());
19bb31ff Andy McCall ↶ 151
    }
152
    if let Err(e) = auth::password_ok(&f.password) {
c40c2d3e Andy McCall ↶ 153
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).error(e)).into_response());
19bb31ff Andy McCall ↶ 154
    }
155
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
156
    // A new session salt signs out every other session; this one is renewed.
157
    accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
158
    accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok();
159
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
160
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
161
}
162
163
#[derive(Template)]
164
#[template(path = "settings_security.html")]
165
pub struct SecurityPage {
166
    shell: Shell,
167
    me: accounts::Account,
168
    notice: Option<String>,
169
    error: Option<String>,
170
    /// Enrolment in progress: secret, URI and QR.
171
    enrol: Option<(String, String, String)>,
172
    recovery: Vec<String>,
173
}
174
175
pub async fn security(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SecurityPage>, Error> {
176
    let shell = Shell::new(&app, "/settings/security", &viewer);
177
    let me = require(&viewer, &shell)?;
178
    Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: None, recovery: vec![] }))
179
}
180
181
#[derive(Deserialize)]
182
pub struct TotpForm {
183
    #[serde(rename = "_csrf")]
184
    csrf_token: String,
185
    #[serde(default)]
186
    secret: String,
187
    #[serde(default)]
188
    code: String,
189
    #[serde(default)]
190
    password: String,
191
}
192
193
/// Step one: show a fresh secret to scan.
194
pub async fn totp_begin(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
195
    let shell = Shell::new(&app, "/settings/security", &viewer);
196
    let me = require(&viewer, &shell)?;
197
    csrf(&viewer, &f.csrf_token, &shell)?;
198
    let secret = totp::new_secret();
199
    let uri = totp::uri(&app.config.site.name, &me.account.username, &secret);
200
    let qr = totp::qr_svg(&uri).unwrap_or_default();
201
    Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: Some((secret, uri, qr)), recovery: vec![] }))
202
}
203
204
/// Step two: the first code proves the app holds the secret.
205
pub async fn totp_enable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
206
    let shell = Shell::new(&app, "/settings/security", &viewer);
207
    let me = require(&viewer, &shell)?;
208
    csrf(&viewer, &f.csrf_token, &shell)?;
209
    let now = (db::now_ms() / 1000) as u64;
210
    if f.secret.is_empty() || !totp::verify(&f.secret, &f.code, now) {
211
        let uri = totp::uri(&app.config.site.name, &me.account.username, &f.secret);
212
        let qr = totp::qr_svg(&uri).unwrap_or_default();
213
        return Ok(Page(SecurityPage {
214
            shell,
215
            me: me.account,
216
            notice: None,
217
            error: Some("That code did not match; scan again and enter the current code.".into()),
218
            enrol: Some((f.secret, uri, qr)),
219
            recovery: vec![],
220
        }));
221
    }
222
    let (codes, stored) = totp::new_recovery_codes();
223
    accounts::set_totp(&app.db, me.account.id, Some(&f.secret), &stored).await.map_err(|e| Error::from_db(shell.clone(), e))?;
224
    accounts::audit(&app.db, Some(&me.account), "totp.enabled", &me.account.username, "").await.ok();
225
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
226
    Ok(Page(SecurityPage {
227
        shell,
228
        me: fresh,
229
        notice: Some("Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once.".into()),
230
        error: None,
231
        enrol: None,
232
        recovery: codes,
233
    }))
234
}
235
236
pub async fn totp_disable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
237
    let shell = Shell::new(&app, "/settings/security", &viewer);
238
    let me = require(&viewer, &shell)?;
239
    csrf(&viewer, &f.csrf_token, &shell)?;
240
    if !auth::verify_password(&me.account.password_hash, &f.password) {
241
        return Ok(Page(SecurityPage {
242
            shell,
243
            me: me.account,
244
            notice: None,
245
            error: Some("Your password is wrong.".into()),
246
            enrol: None,
247
            recovery: vec![],
248
        }));
249
    }
250
    accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
251
    accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok();
252
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
253
    Ok(Page(SecurityPage {
254
        shell,
255
        me: fresh,
256
        notice: Some("Two-factor authentication is off.".into()),
257
        error: None,
258
        enrol: None,
259
        recovery: vec![],
260
    }))
261
}
262
263
/// Sign out everywhere: a new session salt invalidates every cookie, then
264
/// this browser gets a fresh one.
265
pub async fn sessions_reset(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Response, Error> {
266
    let shell = Shell::new(&app, "/settings/security", &viewer);
267
    let me = require(&viewer, &shell)?;
268
    csrf(&viewer, &f.csrf, &shell)?;
269
    accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
270
    accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok();
271
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
272
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
273
}
274
275
// ------------------------------------------------------------------- keys
276
277
#[derive(Template)]
278
#[template(path = "settings_keys.html")]
279
pub struct KeysPage {
280
    shell: Shell,
281
    me: accounts::Account,
282
    keys: Vec<accounts::LinkedIdentity>,
283
    notice: Option<String>,
284
    error: Option<String>,
285
    /// A pending challenge: id, hex bytes, identity name.
286
    pending: Option<(i64, String, String)>,
287
}
288
289
async fn keys_page(
290
    app: &Shared,
291
    shell: Shell,
292
    me: accounts::Account,
293
    notice: Option<String>,
294
    error: Option<String>,
295
    pending: Option<(i64, String, String)>,
296
) -> Result<Page<KeysPage>, Error> {
297
    let keys = accounts::identities_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
298
    Ok(Page(KeysPage { shell, me, keys, notice, error, pending }))
299
}
300
301
pub async fn keys(State(app): State<Shared>, viewer: Viewer) -> Result<Page<KeysPage>, Error> {
302
    let shell = Shell::new(&app, "/settings/keys", &viewer);
303
    let me = require(&viewer, &shell)?;
304
    keys_page(&app, shell, me.account, None, None, None).await
305
}
306
307
/// Upload an identity object (`oxy identity export`): the hub issues a
308
/// challenge to sign with that key.
309
pub async fn keys_upload(State(app): State<Shared>, viewer: Viewer, mut multipart: Multipart) -> Result<Page<KeysPage>, Error> {
310
    let shell = Shell::new(&app, "/settings/keys", &viewer);
311
    let me = require(&viewer, &shell)?;
312
    let mut token = String::new();
313
    let mut object: Vec<u8> = Vec::new();
314
    while let Ok(Some(field)) = multipart.next_field().await {
315
        match field.name() {
316
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
317
            Some("identity") => object = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(),
318
            _ => {}
319
        }
320
    }
321
    csrf(&viewer, &token, &shell)?;
322
    let identity = match Identity::decode(&object) {
323
        Ok(i) => i,
324
        Err(e) => return keys_page(&app, shell, me.account, None, Some(format!("That is not an identity file: {e}")), None).await,
325
    };
326
    let key = identity.hash().to_hex();
327
    if let Some(existing) = accounts::identity_by_key(&app.db, &key).await.map_err(|e| Error::from_db(shell.clone(), e))? {
328
        let msg = if existing.account_id == me.account.id {
329
            "That key is already linked to you."
330
        } else {
331
            "That key is linked to another account."
332
        };
333
        return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await;
334
    }
335
    let bytes = auth::random_hex(32);
336
    let id = accounts::create_challenge(&app.db, me.account.id, &bytes).await.map_err(|e| Error::from_db(shell.clone(), e))?;
337
    sqlx::query("UPDATE challenges SET object = ? WHERE id = ?")
338
        .bind(&object)
339
        .bind(id)
340
        .execute(&app.db)
341
        .await
342
        .map_err(|e| Error::from_db(shell.clone(), e))?;
343
    keys_page(&app, shell, me.account, None, None, Some((id, bytes, identity.name))).await
344
}
345
346
#[derive(Deserialize)]
347
pub struct KeyVerifyForm {
348
    #[serde(rename = "_csrf")]
349
    csrf_token: String,
350
    challenge: i64,
351
    signature: String,
352
}
353
354
pub async fn keys_verify(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyVerifyForm>) -> Result<Page<KeysPage>, Error> {
355
    let shell = Shell::new(&app, "/settings/keys", &viewer);
356
    let me = require(&viewer, &shell)?;
357
    csrf(&viewer, &f.csrf_token, &shell)?;
358
    let object: Option<Vec<u8>> = sqlx::query_scalar("SELECT object FROM challenges WHERE id = ? AND account_id = ?")
359
        .bind(f.challenge)
360
        .bind(me.account.id)
361
        .fetch_optional(&app.db)
362
        .await
363
        .map_err(|e| Error::from_db(shell.clone(), e))?;
364
    let bytes = accounts::take_challenge(&app.db, me.account.id, f.challenge).await.map_err(|e| Error::from_db(shell.clone(), e))?;
365
    let (Some(object), Some(bytes)) = (object, bytes) else {
366
        return keys_page(&app, shell, me.account, None, Some("That challenge has expired; upload the identity again.".into()), None).await;
367
    };
368
    let Ok(identity) = Identity::decode(&object) else {
369
        return keys_page(&app, shell, me.account, None, Some("The stored identity is unreadable.".into()), None).await;
370
    };
371
    let sig_bytes = hex::decode(f.signature.trim()).unwrap_or_default();
372
    let sigs = Signature::decode_list(&sig_bytes).unwrap_or_default();
373
    let challenge = hex::decode(&bytes).unwrap_or_default();
374
    let ok = sigs.first().is_some_and(|sig| oxy_core::identity::verify_challenge(&identity, sig, &challenge).is_ok());
375
    if !ok {
376
        return keys_page(
377
            &app,
378
            shell,
379
            me.account,
380
            None,
381
            Some("The signature does not verify against that identity and challenge.".into()),
382
            None,
383
        )
384
        .await;
385
    }
386
    let key = identity.hash().to_hex();
387
    accounts::link_identity(&app.db, me.account.id, &key, &identity.name, &identity.email, &object)
388
        .await
389
        .map_err(|e| Error::from_db(shell.clone(), e))?;
390
    accounts::audit(&app.db, Some(&me.account), "identity.linked", &key, &identity.name).await.ok();
391
    keys_page(&app, shell, me.account, Some("Key linked. Changes signed with it are now yours on every page.".into()), None, None).await
392
}
393
394
#[derive(Deserialize)]
395
pub struct KeyForm {
396
    #[serde(rename = "_csrf")]
397
    csrf_token: String,
398
    key: String,
399
}
400
401
pub async fn keys_unlink(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyForm>) -> Result<Page<KeysPage>, Error> {
402
    let shell = Shell::new(&app, "/settings/keys", &viewer);
403
    let me = require(&viewer, &shell)?;
404
    csrf(&viewer, &f.csrf_token, &shell)?;
405
    accounts::unlink_identity(&app.db, me.account.id, &f.key).await.map_err(|e| Error::from_db(shell.clone(), e))?;
406
    accounts::audit(&app.db, Some(&me.account), "identity.unlinked", &f.key, "").await.ok();
407
    keys_page(&app, shell, me.account, Some("Key unlinked.".into()), None, None).await
408
}
409
410
// ----------------------------------------------------------------- tokens
411
412
#[derive(Template)]
413
#[template(path = "settings_tokens.html")]
414
pub struct TokensPage {
415
    shell: Shell,
416
    me: accounts::Account,
417
    tokens: Vec<accounts::Token>,
418
    /// A freshly created token, shown once.
419
    fresh: Option<String>,
420
    notice: Option<String>,
421
    error: Option<String>,
422
}
423
424
async fn tokens_page(
425
    app: &Shared,
426
    shell: Shell,
427
    me: accounts::Account,
428
    fresh: Option<String>,
429
    notice: Option<String>,
430
    error: Option<String>,
431
) -> Result<Page<TokensPage>, Error> {
432
    let tokens = accounts::tokens_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
433
    Ok(Page(TokensPage { shell, me, tokens, fresh, notice, error }))
434
}
435
436
pub async fn tokens(State(app): State<Shared>, viewer: Viewer) -> Result<Page<TokensPage>, Error> {
437
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
438
    let me = require(&viewer, &shell)?;
439
    tokens_page(&app, shell, me.account, None, None, None).await
440
}
441
442
#[derive(Deserialize)]
443
pub struct TokenForm {
444
    #[serde(rename = "_csrf")]
445
    csrf_token: String,
446
    name: String,
447
    #[serde(default)]
448
    scopes: Vec<String>,
449
    #[serde(default)]
450
    repos: String,
451
    #[serde(default)]
452
    expires_days: String,
453
}
454
455
pub const SCOPES: &[(&str, &str)] = &[
456
    ("repo:read", "read repositories you can see (clone, fetch, pull)"),
457
    ("repo:write", "push to repositories you can write to"),
458
    ("repo:admin", "change repository settings through the API"),
459
    ("user:read", "read your profile through the API"),
460
];
461
462
pub async fn tokens_create(
463
    State(app): State<Shared>,
464
    viewer: Viewer,
465
    axum_extra::extract::Form(f): axum_extra::extract::Form<TokenForm>,
466
) -> Result<Page<TokensPage>, Error> {
467
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
468
    let me = require(&viewer, &shell)?;
469
    csrf(&viewer, &f.csrf_token, &shell)?;
470
    let name = f.name.trim();
471
    if name.is_empty() {
472
        return tokens_page(&app, shell, me.account, None, None, Some("Give the token a name.".into())).await;
473
    }
474
    let scopes: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| SCOPES.iter().any(|(k, _)| k == s)).collect();
475
    if scopes.is_empty() {
476
        return tokens_page(&app, shell, me.account, None, None, Some("Pick at least one scope.".into())).await;
477
    }
478
    let expires_ms = f.expires_days.trim().parse::<i64>().ok().filter(|d| *d > 0).map(|d| db::now_ms() + d * 86_400_000);
479
    let repos = f.repos.split(',').map(str::trim).filter(|r| !r.is_empty()).collect::<Vec<_>>().join(",");
480
    let (secret, hash) = auth::new_token();
481
    accounts::create_token(&app.db, me.account.id, name, &hash, &scopes.join(","), &repos, expires_ms)
482
        .await
483
        .map_err(|e| Error::from_db(shell.clone(), e))?;
484
    accounts::audit(&app.db, Some(&me.account), "token.created", name, &scopes.join(",")).await.ok();
485
    tokens_page(&app, shell, me.account, Some(secret), Some("Copy the token now; it is not shown again.".into()), None).await
486
}
487
488
pub async fn tokens_delete(
489
    State(app): State<Shared>,
490
    viewer: Viewer,
491
    Path(id): Path<i64>,
492
    Form(f): Form<super::session::CsrfForm>,
493
) -> Result<Page<TokensPage>, Error> {
494
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
495
    let me = require(&viewer, &shell)?;
496
    csrf(&viewer, &f.csrf, &shell)?;
497
    accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
498
    accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok();
499
    tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await
500
}