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