anvilsign in

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
6767 GET/HEAD only — least-privilege read-only (writes need a session CSRF a bearer
6868 lacks). Lets tooling (and Claude) fetch private-repo attachments over HTTP.
6969 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.
7274
7375 ## UI polish (done)
7476
⋯ 6 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+110 −3
⋯ 15 unchanged lines
1616 };
1717
1818 use anvil_core::{
19+ ApiToken,
1920 App,
2021 CiRun,
2122 Repository,
2223 SshKey,
2324 User,
2425 access,
26+ api_tokens,
2527 ci,
2628 repos,
2729 ssh_keys,
⋯ 270 unchanged lines
298300 .route("/-/settings", get(account_settings))
299301 .route("/-/settings/keys", post(add_ssh_key))
300302 .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))
301305 .route("/-/new", get(new_repo_form).post(new_repo_submit))
302306 .route("/{username}", get(user_profile))
303307 .route(
⋯ 244 unchanged lines
548552 Ok(keys) => keys,
549553 Err(e) => return server_error(e),
550554 };
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()
552557 }
553558
554559 /// `POST /settings/keys` — register an SSH public key for the current user.
⋯ 23 unchanged lines
578583 let keys = ssh_keys::list_by_user(&app.db, user.id)
579584 .await
580585 .unwrap_or_default();
586+ let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default();
581587 (
582588 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),
584590 )
585591 .into_response()
586592 }
587593 }
588594 }
589595
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+
590657 /// `POST /settings/keys/{id}/delete` — remove one of the current user's keys.
591658 async fn delete_ssh_key(
592659 State(app): State<App>,
⋯ 14 unchanged lines
607674 Redirect::to("/-/settings").into_response()
608675 }
609676
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 {
611686 layout(
612687 "Account settings",
613688 Some(user),
⋯ 33 unchanged lines
647722 p { label { "Public key" br; textarea name="key" rows="4" placeholder="ssh-ed25519 AAAA…" {} } }
648723 p { button.btn type="submit" { "Add SSH key" } }
649724 }
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+ }
650757 },
651758 )
652759 }
⋯ 1720 unchanged lines