OXYGENHUB
# 045f967db8f2 oxygenhub / crates / hub / src / web / settings.rs
21.4 KiB · Rust · last changed Profile and organisation pages, with identicons 1 month ago source blame history raw
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};
10
use axum::http::{header, HeaderMap, StatusCode};
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>,
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
    }
74
}
75
76
pub async fn profile(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap) -> Result<Page<SettingsPage>, Error> {
77
    let shell = Shell::new(&app, "/settings", &viewer);
78
    let me = require(&viewer, &shell)?;
79
    Ok(Page(SettingsPage::new(shell, me.account, &headers)))
80
}
81
82
#[derive(Deserialize)]
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)]
108
pub struct ProfileForm {
109
    #[serde(rename = "_csrf")]
110
    csrf_token: String,
111
    display_name: String,
112
    email: String,
113
    #[serde(default)]
114
    bio: String,
115
    #[serde(default)]
116
    website: String,
117
    #[serde(default)]
118
    location: String,
119
}
120
121
pub async fn update_profile(
122
    State(app): State<Shared>,
123
    viewer: Viewer,
124
    headers: HeaderMap,
125
    Form(f): Form<ProfileForm>,
126
) -> Result<Page<SettingsPage>, Error> {
127
    let shell = Shell::new(&app, "/settings", &viewer);
128
    let me = require(&viewer, &shell)?;
129
    csrf(&viewer, &f.csrf_token, &shell)?;
130
    let website = f.website.trim();
131
    if !(website.is_empty() || website.starts_with("https://") || website.starts_with("http://")) || website.len() > 200 {
132
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).error("The website must be an http:// or https:// address.")));
133
    }
134
    let bio: String = f.bio.trim().chars().take(280).collect();
135
    let location: String = f.location.trim().chars().take(80).collect();
136
    accounts::set_profile(&app.db, me.account.id, f.display_name.trim(), f.email.trim())
137
        .await
138
        .map_err(|e| Error::from_db(shell.clone(), e))?;
139
    accounts::set_bio(&app.db, me.account.id, &bio, website, &location).await.map_err(|e| Error::from_db(shell.clone(), e))?;
140
    let updated =
141
        accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
142
    Ok(Page(SettingsPage::new(shell, updated, &headers).notice("Profile saved.")))
143
}
144
145
#[derive(Deserialize)]
146
pub struct PasswordForm {
147
    #[serde(rename = "_csrf")]
148
    csrf_token: String,
149
    current: String,
150
    password: String,
151
}
152
153
pub async fn change_password(
154
    State(app): State<Shared>,
155
    viewer: Viewer,
156
    headers: HeaderMap,
157
    Form(f): Form<PasswordForm>,
158
) -> Result<Response, Error> {
159
    let shell = Shell::new(&app, "/settings", &viewer);
160
    let me = require(&viewer, &shell)?;
161
    csrf(&viewer, &f.csrf_token, &shell)?;
162
    if !auth::verify_password(&me.account.password_hash, &f.current) {
163
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).error("Your current password is wrong.")).into_response());
164
    }
165
    if let Err(e) = auth::password_ok(&f.password) {
166
        return Ok(Page(SettingsPage::new(shell, me.account, &headers).error(e)).into_response());
167
    }
168
    let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
169
    // A new session salt signs out every other session; this one is renewed.
170
    accounts::set_password(&app.db, me.account.id, &hash, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
171
    accounts::audit(&app.db, Some(&me.account), "password.changed", &me.account.username, "").await.ok();
172
    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);
173
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
174
}
175
176
#[derive(Template)]
177
#[template(path = "settings_security.html")]
178
pub struct SecurityPage {
179
    shell: Shell,
180
    me: accounts::Account,
181
    notice: Option<String>,
182
    error: Option<String>,
183
    /// Enrolment in progress: secret, URI and QR.
184
    enrol: Option<(String, String, String)>,
185
    recovery: Vec<String>,
186
}
187
188
pub async fn security(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SecurityPage>, Error> {
189
    let shell = Shell::new(&app, "/settings/security", &viewer);
190
    let me = require(&viewer, &shell)?;
191
    Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: None, recovery: vec![] }))
192
}
193
194
#[derive(Deserialize)]
195
pub struct TotpForm {
196
    #[serde(rename = "_csrf")]
197
    csrf_token: String,
198
    #[serde(default)]
199
    secret: String,
200
    #[serde(default)]
201
    code: String,
202
    #[serde(default)]
203
    password: String,
204
}
205
206
/// Step one: show a fresh secret to scan.
207
pub async fn totp_begin(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
208
    let shell = Shell::new(&app, "/settings/security", &viewer);
209
    let me = require(&viewer, &shell)?;
210
    csrf(&viewer, &f.csrf_token, &shell)?;
211
    let secret = totp::new_secret();
212
    let uri = totp::uri(&app.config.site.name, &me.account.username, &secret);
213
    let qr = totp::qr_svg(&uri).unwrap_or_default();
214
    Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: Some((secret, uri, qr)), recovery: vec![] }))
215
}
216
217
/// Step two: the first code proves the app holds the secret.
218
pub async fn totp_enable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
219
    let shell = Shell::new(&app, "/settings/security", &viewer);
220
    let me = require(&viewer, &shell)?;
221
    csrf(&viewer, &f.csrf_token, &shell)?;
222
    let now = (db::now_ms() / 1000) as u64;
223
    if f.secret.is_empty() || !totp::verify(&f.secret, &f.code, now) {
224
        let uri = totp::uri(&app.config.site.name, &me.account.username, &f.secret);
225
        let qr = totp::qr_svg(&uri).unwrap_or_default();
226
        return Ok(Page(SecurityPage {
227
            shell,
228
            me: me.account,
229
            notice: None,
230
            error: Some("That code did not match; scan again and enter the current code.".into()),
231
            enrol: Some((f.secret, uri, qr)),
232
            recovery: vec![],
233
        }));
234
    }
235
    let (codes, stored) = totp::new_recovery_codes();
236
    accounts::set_totp(&app.db, me.account.id, Some(&f.secret), &stored).await.map_err(|e| Error::from_db(shell.clone(), e))?;
237
    accounts::audit(&app.db, Some(&me.account), "totp.enabled", &me.account.username, "").await.ok();
238
    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);
239
    Ok(Page(SecurityPage {
240
        shell,
241
        me: fresh,
242
        notice: Some("Two-factor authentication is on. Keep these recovery codes somewhere safe; each works once.".into()),
243
        error: None,
244
        enrol: None,
245
        recovery: codes,
246
    }))
247
}
248
249
pub async fn totp_disable(State(app): State<Shared>, viewer: Viewer, Form(f): Form<TotpForm>) -> Result<Page<SecurityPage>, Error> {
250
    let shell = Shell::new(&app, "/settings/security", &viewer);
251
    let me = require(&viewer, &shell)?;
252
    csrf(&viewer, &f.csrf_token, &shell)?;
253
    if !auth::verify_password(&me.account.password_hash, &f.password) {
254
        return Ok(Page(SecurityPage {
255
            shell,
256
            me: me.account,
257
            notice: None,
258
            error: Some("Your password is wrong.".into()),
259
            enrol: None,
260
            recovery: vec![],
261
        }));
262
    }
263
    accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?;
264
    accounts::audit(&app.db, Some(&me.account), "totp.disabled", &me.account.username, "").await.ok();
265
    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);
266
    Ok(Page(SecurityPage {
267
        shell,
268
        me: fresh,
269
        notice: Some("Two-factor authentication is off.".into()),
270
        error: None,
271
        enrol: None,
272
        recovery: vec![],
273
    }))
274
}
275
276
/// Sign out everywhere: a new session salt invalidates every cookie, then
277
/// this browser gets a fresh one.
278
pub async fn sessions_reset(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Response, Error> {
279
    let shell = Shell::new(&app, "/settings/security", &viewer);
280
    let me = require(&viewer, &shell)?;
281
    csrf(&viewer, &f.csrf, &shell)?;
282
    accounts::set_session_salt(&app.db, me.account.id, &auth::random_hex(16)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
283
    accounts::audit(&app.db, Some(&me.account), "sessions.reset", &me.account.username, "").await.ok();
284
    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);
285
    Ok(super::session::signed_in(&app, &fresh, "/settings/security"))
286
}
287
288
// ------------------------------------------------------------------- keys
289
290
#[derive(Template)]
291
#[template(path = "settings_keys.html")]
292
pub struct KeysPage {
293
    shell: Shell,
294
    me: accounts::Account,
295
    keys: Vec<accounts::LinkedIdentity>,
296
    notice: Option<String>,
297
    error: Option<String>,
298
    /// A pending challenge: id, hex bytes, identity name.
299
    pending: Option<(i64, String, String)>,
300
}
301
302
async fn keys_page(
303
    app: &Shared,
304
    shell: Shell,
305
    me: accounts::Account,
306
    notice: Option<String>,
307
    error: Option<String>,
308
    pending: Option<(i64, String, String)>,
309
) -> Result<Page<KeysPage>, Error> {
310
    let keys = accounts::identities_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
311
    Ok(Page(KeysPage { shell, me, keys, notice, error, pending }))
312
}
313
314
pub async fn keys(State(app): State<Shared>, viewer: Viewer) -> Result<Page<KeysPage>, Error> {
315
    let shell = Shell::new(&app, "/settings/keys", &viewer);
316
    let me = require(&viewer, &shell)?;
317
    keys_page(&app, shell, me.account, None, None, None).await
318
}
319
320
/// Upload an identity object (`oxy identity export`): the hub issues a
321
/// challenge to sign with that key.
322
pub async fn keys_upload(State(app): State<Shared>, viewer: Viewer, mut multipart: Multipart) -> Result<Page<KeysPage>, Error> {
323
    let shell = Shell::new(&app, "/settings/keys", &viewer);
324
    let me = require(&viewer, &shell)?;
325
    let mut token = String::new();
326
    let mut object: Vec<u8> = Vec::new();
327
    while let Ok(Some(field)) = multipart.next_field().await {
328
        match field.name() {
329
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
330
            Some("identity") => object = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(),
331
            _ => {}
332
        }
333
    }
334
    csrf(&viewer, &token, &shell)?;
335
    let identity = match Identity::decode(&object) {
336
        Ok(i) => i,
337
        Err(e) => return keys_page(&app, shell, me.account, None, Some(format!("That is not an identity file: {e}")), None).await,
338
    };
339
    let key = identity.hash().to_hex();
340
    if let Some(existing) = accounts::identity_by_key(&app.db, &key).await.map_err(|e| Error::from_db(shell.clone(), e))? {
341
        let msg = if existing.account_id == me.account.id {
342
            "That key is already linked to you."
343
        } else {
344
            "That key is linked to another account."
345
        };
346
        return keys_page(&app, shell, me.account, None, Some(msg.into()), None).await;
347
    }
348
    let bytes = auth::random_hex(32);
349
    let id = accounts::create_challenge(&app.db, me.account.id, &bytes).await.map_err(|e| Error::from_db(shell.clone(), e))?;
350
    sqlx::query("UPDATE challenges SET object = ? WHERE id = ?")
351
        .bind(&object)
352
        .bind(id)
353
        .execute(&app.db)
354
        .await
355
        .map_err(|e| Error::from_db(shell.clone(), e))?;
356
    keys_page(&app, shell, me.account, None, None, Some((id, bytes, identity.name))).await
357
}
358
359
#[derive(Deserialize)]
360
pub struct KeyVerifyForm {
361
    #[serde(rename = "_csrf")]
362
    csrf_token: String,
363
    challenge: i64,
364
    signature: String,
365
}
366
367
pub async fn keys_verify(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyVerifyForm>) -> Result<Page<KeysPage>, Error> {
368
    let shell = Shell::new(&app, "/settings/keys", &viewer);
369
    let me = require(&viewer, &shell)?;
370
    csrf(&viewer, &f.csrf_token, &shell)?;
371
    let object: Option<Vec<u8>> = sqlx::query_scalar("SELECT object FROM challenges WHERE id = ? AND account_id = ?")
372
        .bind(f.challenge)
373
        .bind(me.account.id)
374
        .fetch_optional(&app.db)
375
        .await
376
        .map_err(|e| Error::from_db(shell.clone(), e))?;
377
    let bytes = accounts::take_challenge(&app.db, me.account.id, f.challenge).await.map_err(|e| Error::from_db(shell.clone(), e))?;
378
    let (Some(object), Some(bytes)) = (object, bytes) else {
379
        return keys_page(&app, shell, me.account, None, Some("That challenge has expired; upload the identity again.".into()), None).await;
380
    };
381
    let Ok(identity) = Identity::decode(&object) else {
382
        return keys_page(&app, shell, me.account, None, Some("The stored identity is unreadable.".into()), None).await;
383
    };
384
    let sig_bytes = hex::decode(f.signature.trim()).unwrap_or_default();
385
    let sigs = Signature::decode_list(&sig_bytes).unwrap_or_default();
386
    let challenge = hex::decode(&bytes).unwrap_or_default();
387
    let ok = sigs.first().is_some_and(|sig| oxy_core::identity::verify_challenge(&identity, sig, &challenge).is_ok());
388
    if !ok {
389
        return keys_page(
390
            &app,
391
            shell,
392
            me.account,
393
            None,
394
            Some("The signature does not verify against that identity and challenge.".into()),
395
            None,
396
        )
397
        .await;
398
    }
399
    let key = identity.hash().to_hex();
400
    accounts::link_identity(&app.db, me.account.id, &key, &identity.name, &identity.email, &object)
401
        .await
402
        .map_err(|e| Error::from_db(shell.clone(), e))?;
403
    accounts::audit(&app.db, Some(&me.account), "identity.linked", &key, &identity.name).await.ok();
404
    keys_page(&app, shell, me.account, Some("Key linked. Changes signed with it are now yours on every page.".into()), None, None).await
405
}
406
407
#[derive(Deserialize)]
408
pub struct KeyForm {
409
    #[serde(rename = "_csrf")]
410
    csrf_token: String,
411
    key: String,
412
}
413
414
pub async fn keys_unlink(State(app): State<Shared>, viewer: Viewer, Form(f): Form<KeyForm>) -> Result<Page<KeysPage>, Error> {
415
    let shell = Shell::new(&app, "/settings/keys", &viewer);
416
    let me = require(&viewer, &shell)?;
417
    csrf(&viewer, &f.csrf_token, &shell)?;
418
    accounts::unlink_identity(&app.db, me.account.id, &f.key).await.map_err(|e| Error::from_db(shell.clone(), e))?;
419
    accounts::audit(&app.db, Some(&me.account), "identity.unlinked", &f.key, "").await.ok();
420
    keys_page(&app, shell, me.account, Some("Key unlinked.".into()), None, None).await
421
}
422
423
// ----------------------------------------------------------------- tokens
424
425
#[derive(Template)]
426
#[template(path = "settings_tokens.html")]
427
pub struct TokensPage {
428
    shell: Shell,
429
    scopes: &'static [(&'static str, &'static str)],
430
    me: accounts::Account,
431
    tokens: Vec<accounts::Token>,
432
    /// A freshly created token, shown once.
433
    fresh: Option<String>,
434
    notice: Option<String>,
435
    error: Option<String>,
436
}
437
438
async fn tokens_page(
439
    app: &Shared,
440
    shell: Shell,
441
    me: accounts::Account,
442
    fresh: Option<String>,
443
    notice: Option<String>,
444
    error: Option<String>,
445
) -> Result<Page<TokensPage>, Error> {
446
    let tokens = accounts::tokens_of(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
447
    Ok(Page(TokensPage { shell, scopes: SCOPES, me, tokens, fresh, notice, error }))
448
}
449
450
pub async fn tokens(State(app): State<Shared>, viewer: Viewer) -> Result<Page<TokensPage>, Error> {
451
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
452
    let me = require(&viewer, &shell)?;
453
    tokens_page(&app, shell, me.account, None, None, None).await
454
}
455
456
#[derive(Deserialize)]
457
pub struct TokenForm {
458
    #[serde(rename = "_csrf")]
459
    csrf_token: String,
460
    name: String,
461
    #[serde(default)]
462
    scopes: Vec<String>,
463
    #[serde(default)]
464
    repos: String,
465
    #[serde(default)]
466
    expires_days: String,
467
}
468
469
/// Every scope the hub checks, with what it grants. The tokens page lists
470
/// exactly these, so a scope cannot exist without a checkbox.
471
pub const SCOPES: &[(&str, &str)] = &[
472
    ("repo:read", "read repositories you can see: clone, fetch, pull, and the read API"),
473
    ("repo:write", "push to repositories you can write to; also covers proposals, issues and checks"),
474
    ("repo:admin", "create repositories and change settings through the API"),
475
    ("proposals:write", "open, comment on and review proposals, without push access"),
476
    ("issues:write", "open, comment on and close issues, without push access"),
477
    ("checks:write", "report checks on states, for CI systems, without push access"),
478
    ("hooks:admin", "manage webhooks through the API"),
479
    ("user:read", "read your own profile through the API"),
480
];
481
482
pub async fn tokens_create(
483
    State(app): State<Shared>,
484
    viewer: Viewer,
485
    axum_extra::extract::Form(f): axum_extra::extract::Form<TokenForm>,
486
) -> Result<Page<TokensPage>, Error> {
487
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
488
    let me = require(&viewer, &shell)?;
489
    csrf(&viewer, &f.csrf_token, &shell)?;
490
    let name = f.name.trim();
491
    if name.is_empty() {
492
        return tokens_page(&app, shell, me.account, None, None, Some("Give the token a name.".into())).await;
493
    }
494
    let scopes: Vec<&str> = f.scopes.iter().map(String::as_str).filter(|s| SCOPES.iter().any(|(k, _)| k == s)).collect();
495
    if scopes.is_empty() {
496
        return tokens_page(&app, shell, me.account, None, None, Some("Pick at least one scope.".into())).await;
497
    }
498
    let expires_ms = f.expires_days.trim().parse::<i64>().ok().filter(|d| *d > 0).map(|d| db::now_ms() + d * 86_400_000);
499
    let repos = f.repos.split(',').map(str::trim).filter(|r| !r.is_empty()).collect::<Vec<_>>().join(",");
500
    let (secret, hash) = auth::new_token();
501
    accounts::create_token(&app.db, me.account.id, name, &hash, &scopes.join(","), &repos, expires_ms)
502
        .await
503
        .map_err(|e| Error::from_db(shell.clone(), e))?;
504
    accounts::audit(&app.db, Some(&me.account), "token.created", name, &scopes.join(",")).await.ok();
505
    tokens_page(&app, shell, me.account, Some(secret), Some("Copy the token now; it is not shown again.".into()), None).await
506
}
507
508
pub async fn tokens_delete(
509
    State(app): State<Shared>,
510
    viewer: Viewer,
511
    Path(id): Path<i64>,
512
    Form(f): Form<super::session::CsrfForm>,
513
) -> Result<Page<TokensPage>, Error> {
514
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
515
    let me = require(&viewer, &shell)?;
516
    csrf(&viewer, &f.csrf, &shell)?;
517
    accounts::delete_token(&app.db, me.account.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
518
    accounts::audit(&app.db, Some(&me.account), "token.deleted", &id.to_string(), "").await.ok();
519
    tokens_page(&app, shell, me.account, None, Some("Token deleted.".into()), None).await
520
}