collin/anvil · dcae7146
feat: manage personal access tokens from the settings page
Collin Richards · 2026-06-10 14:32 UTC · dcae7146078d96586d616365e8449a10322fe11b · parent 91f548f3 · browse files
modifiedTODO.md+4 −2
| ⋯ 66 unchanged lines | |||
| 67 | 67 | GET/HEAD only — least-privilege read-only (writes need a session CSRF a bearer | |
| 68 | 68 | lacks). Lets tooling (and Claude) fetch private-repo attachments over HTTP. | |
| 69 | 69 | See the recipe in `CLAUDE.md`. | |
| 70 | - | - [ ] maybe later: a `write` scope (would need CSRF-exempt write paths), token | |
| 71 | - | management in the web UI, and `last_used_at` tracking. | |
| 70 | + | - [x] token management on the user settings page (`/-/settings`): create (secret | |
| 71 | + | shown once), list, and revoke — ownership-enforced. | |
| 72 | + | - [ ] maybe later: a `write` scope (would need CSRF-exempt write paths) and | |
| 73 | + | `last_used_at` tracking. | |
| 72 | 74 | ||
| 73 | 75 | ## UI polish (done) | |
| 74 | 76 | ||
| ⋯ 6 unchanged lines | |||
modifiedcrates/anvil-web/src/ui.rs+110 −3
| ⋯ 15 unchanged lines | |||
| 16 | 16 | }; | |
| 17 | 17 | ||
| 18 | 18 | use anvil_core::{ | |
| 19 | + | ApiToken, | |
| 19 | 20 | App, | |
| 20 | 21 | CiRun, | |
| 21 | 22 | Repository, | |
| 22 | 23 | SshKey, | |
| 23 | 24 | User, | |
| 24 | 25 | access, | |
| 26 | + | api_tokens, | |
| 25 | 27 | ci, | |
| 26 | 28 | repos, | |
| 27 | 29 | ssh_keys, | |
| ⋯ 270 unchanged lines | |||
| 298 | 300 | .route("/-/settings", get(account_settings)) | |
| 299 | 301 | .route("/-/settings/keys", post(add_ssh_key)) | |
| 300 | 302 | .route("/-/settings/keys/{id}/delete", post(delete_ssh_key)) | |
| 303 | + | .route("/-/settings/tokens", post(create_token)) | |
| 304 | + | .route("/-/settings/tokens/{id}/delete", post(revoke_token)) | |
| 301 | 305 | .route("/-/new", get(new_repo_form).post(new_repo_submit)) | |
| 302 | 306 | .route("/{username}", get(user_profile)) | |
| 303 | 307 | .route( | |
| ⋯ 244 unchanged lines | |||
| 548 | 552 | Ok(keys) => keys, | |
| 549 | 553 | Err(e) => return server_error(e), | |
| 550 | 554 | }; | |
| 551 | - | account_page(&user, &keys, None, &csrf.0).into_response() | |
| 555 | + | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 556 | + | account_page(&user, &keys, &tokens, None, None, &csrf.0).into_response() | |
| 552 | 557 | } | |
| 553 | 558 | ||
| 554 | 559 | /// `POST /settings/keys` — register an SSH public key for the current user. | |
| ⋯ 23 unchanged lines | |||
| 578 | 583 | let keys = ssh_keys::list_by_user(&app.db, user.id) | |
| 579 | 584 | .await | |
| 580 | 585 | .unwrap_or_default(); | |
| 586 | + | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 581 | 587 | ( | |
| 582 | 588 | StatusCode::BAD_REQUEST, | |
| 583 | - | account_page(&user, &keys, Some(&e.to_string()), &csrf.0), | |
| 589 | + | account_page(&user, &keys, &tokens, None, Some(&e.to_string()), &csrf.0), | |
| 584 | 590 | ) | |
| 585 | 591 | .into_response() | |
| 586 | 592 | } | |
| 587 | 593 | } | |
| 588 | 594 | } | |
| 589 | 595 | ||
| 596 | + | #[derive(serde::Deserialize)] | |
| 597 | + | struct CreateTokenForm { | |
| 598 | + | #[serde(default)] | |
| 599 | + | name: String, | |
| 600 | + | #[serde(default)] | |
| 601 | + | csrf: String, | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | /// `POST /settings/tokens` — mint a read-only PAT for the current user and show | |
| 605 | + | /// the plaintext once (it's only stored hashed, so it can't be shown again). | |
| 606 | + | async fn create_token( | |
| 607 | + | State(app): State<App>, | |
| 608 | + | CurrentUser(user): CurrentUser, | |
| 609 | + | csrf: Csrf, | |
| 610 | + | Form(form): Form<CreateTokenForm>, | |
| 611 | + | ) -> Response { | |
| 612 | + | let Some(user) = user else { | |
| 613 | + | return Redirect::to("/-/login").into_response(); | |
| 614 | + | }; | |
| 615 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 616 | + | return resp; | |
| 617 | + | } | |
| 618 | + | let name = match form.name.trim() { | |
| 619 | + | "" => "api", | |
| 620 | + | n => n, | |
| 621 | + | }; | |
| 622 | + | let plaintext = match api_tokens::create(&app.db, user.id, name, api_tokens::READ).await { | |
| 623 | + | Ok((_, plaintext)) => plaintext, | |
| 624 | + | Err(e) => return server_error(e), | |
| 625 | + | }; | |
| 626 | + | let keys = ssh_keys::list_by_user(&app.db, user.id) | |
| 627 | + | .await | |
| 628 | + | .unwrap_or_default(); | |
| 629 | + | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 630 | + | account_page(&user, &keys, &tokens, Some(&plaintext), None, &csrf.0).into_response() | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | /// `POST /settings/tokens/{id}/delete` — revoke one of the current user's | |
| 634 | + | /// tokens (ownership enforced: a user can only revoke their own). | |
| 635 | + | async fn revoke_token( | |
| 636 | + | State(app): State<App>, | |
| 637 | + | CurrentUser(user): CurrentUser, | |
| 638 | + | csrf: Csrf, | |
| 639 | + | Path(id): Path<i64>, | |
| 640 | + | Form(form): Form<crate::auth::CsrfForm>, | |
| 641 | + | ) -> Response { | |
| 642 | + | let Some(user) = user else { | |
| 643 | + | return Redirect::to("/-/login").into_response(); | |
| 644 | + | }; | |
| 645 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 646 | + | return resp; | |
| 647 | + | } | |
| 648 | + | let owned = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 649 | + | if owned.iter().any(|t| t.id == id) | |
| 650 | + | && let Err(e) = api_tokens::revoke(&app.db, id).await | |
| 651 | + | { | |
| 652 | + | return server_error(e); | |
| 653 | + | } | |
| 654 | + | Redirect::to("/-/settings").into_response() | |
| 655 | + | } | |
| 656 | + | ||
| 590 | 657 | /// `POST /settings/keys/{id}/delete` — remove one of the current user's keys. | |
| 591 | 658 | async fn delete_ssh_key( | |
| 592 | 659 | State(app): State<App>, | |
| ⋯ 14 unchanged lines | |||
| 607 | 674 | Redirect::to("/-/settings").into_response() | |
| 608 | 675 | } | |
| 609 | 676 | ||
| 610 | - | fn account_page(user: &User, keys: &[SshKey], error: Option<&str>, csrf: &str) -> Markup { | |
| 677 | + | #[allow(clippy::too_many_arguments)] | |
| 678 | + | fn account_page( | |
| 679 | + | user: &User, | |
| 680 | + | keys: &[SshKey], | |
| 681 | + | tokens: &[ApiToken], | |
| 682 | + | new_token: Option<&str>, | |
| 683 | + | error: Option<&str>, | |
| 684 | + | csrf: &str, | |
| 685 | + | ) -> Markup { | |
| 611 | 686 | layout( | |
| 612 | 687 | "Account settings", | |
| 613 | 688 | Some(user), | |
| ⋯ 33 unchanged lines | |||
| 647 | 722 | p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } } | |
| 648 | 723 | p { button.btn type="submit" { "Add SSH key" } } | |
| 649 | 724 | } | |
| 725 | + | ||
| 726 | + | h2 style="margin-top:28px" { "Personal access tokens" } | |
| 727 | + | p.muted { "Read-only API tokens for tooling (e.g. fetching attachments over HTTP). The secret is shown once, at creation." } | |
| 728 | + | @if let Some(token) = new_token { | |
| 729 | + | div.box style="border-color:var(--accent)" { | |
| 730 | + | p style="margin-top:0" { strong { "New token — copy it now; it won't be shown again." } } | |
| 731 | + | pre.cmds { (token) } | |
| 732 | + | } | |
| 733 | + | } | |
| 734 | + | @if tokens.is_empty() { | |
| 735 | + | p.muted { "No tokens yet." } | |
| 736 | + | } @else { | |
| 737 | + | div.box { | |
| 738 | + | @for t in tokens { | |
| 739 | + | div.row { | |
| 740 | + | div { | |
| 741 | + | strong { (t.name) } " " span.pill { (t.scopes) } | |
| 742 | + | div.muted style="font-size:12px" { "added " (fmt_time(t.created_at)) } | |
| 743 | + | } | |
| 744 | + | form method="post" action=(format!("/-/settings/tokens/{}/delete", t.id)) { | |
| 745 | + | (csrf_input(csrf)) | |
| 746 | + | button.linkbtn type="submit" { "revoke" } | |
| 747 | + | } | |
| 748 | + | } | |
| 749 | + | } | |
| 750 | + | } | |
| 751 | + | } | |
| 752 | + | form.stack method="post" action="/-/settings/tokens" style="margin-top:16px" { | |
| 753 | + | (csrf_input(csrf)) | |
| 754 | + | p { label { "Name" br; input type="text" name="name" placeholder="claude"; } } | |
| 755 | + | p { button.btn type="submit" { "Create token" } } | |
| 756 | + | } | |
| 650 | 757 | }, | |
| 651 | 758 | ) | |
| 652 | 759 | } | |
| ⋯ 1720 unchanged lines | |||