fabrica

hanna/fabrica

feat(web): account settings — credentials and API tokens

339469b · hanna committed on 2026-07-25

Expand /settings with an Account section (change username and email, both
uniqueness-checked via new store update_username/update_email) and a Password
section (re-verifies the current password, then rotates it and re-establishes
this session since set_password clears all). Add an API tokens section:
list, create (scope checkboxes; the JWT is shown once), and revoke.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
5 files changed · +453 −12UnifiedSplit
assets/base.css +33 −0
@@ -359,6 +359,39 @@ select:focus {
359359 margin: 0.25rem 0 0.5rem;
360360 font-size: 0.85em;
361361 }
362+
363+/* API token settings. */
364+.token-secret {
365+ margin: 0.5rem 0 0;
366+ padding: 0.6rem 0.85rem;
367+ overflow-x: auto;
368+ background: var(--fb-bg-inset);
369+ border-radius: var(--fb-radius);
370+}
371+.token-list {
372+ margin-bottom: 1rem;
373+}
374+.token-scopes {
375+ margin: 0.75rem 0 0;
376+ padding: 0.5rem 0.85rem;
377+ border: 1px solid var(--fb-border);
378+ border-radius: var(--fb-radius);
379+}
380+.token-scopes legend {
381+ padding: 0 0.35rem;
382+ font-size: 0.9em;
383+ font-weight: 600;
384+}
385+label.checkbox {
386+ display: inline-flex;
387+ align-items: center;
388+ gap: 0.3rem;
389+ margin: 0 1rem 0 0;
390+ font-weight: 400;
391+}
392+label.checkbox input {
393+ width: auto;
394+}
362395 /* Link slots stack full-width; the Add button sits beside the last one. */
363396 .link-fields {
364397 display: flex;
crates/store/src/users.rs +48 −0
@@ -178,6 +178,54 @@ impl Store {
178178 .map_err(Into::into)
179179 }
180180
181+ /// Change a user's username (case-preserved, case-insensitively unique).
182+ ///
183+ /// # Errors
184+ ///
185+ /// * [`StoreError::Name`] if the username is invalid.
186+ /// * [`StoreError::Conflict`] if the username is already taken.
187+ /// * [`StoreError::Query`] for any other database failure.
188+ pub async fn update_username(
189+ &self,
190+ user_id: &str,
191+ new_username: &str,
192+ ) -> Result<bool, StoreError> {
193+ model::validate_name(new_username)?;
194+ let updated = sqlx::query(
195+ "UPDATE users SET username = $1, username_lower = $2, updated_at = $3 WHERE id = $4",
196+ )
197+ .bind(new_username)
198+ .bind(new_username.to_lowercase())
199+ .bind(now_ms())
200+ .bind(user_id)
201+ .execute(&self.pool)
202+ .await
203+ .map_err(|e| map_conflict(e, "user", &[("username", "username")]))?
204+ .rows_affected();
205+ Ok(updated > 0)
206+ }
207+
208+ /// Change a user's email (case-insensitively unique).
209+ ///
210+ /// # Errors
211+ ///
212+ /// * [`StoreError::Conflict`] if the email is already taken.
213+ /// * [`StoreError::Query`] for any other database failure.
214+ pub async fn update_email(&self, user_id: &str, new_email: &str) -> Result<bool, StoreError> {
215+ let updated = sqlx::query(
216+ "UPDATE users SET email = $1, email_lower = $2, updated_at = $3 WHERE id = $4",
217+ )
218+ .bind(new_email)
219+ .bind(new_email.to_lowercase())
220+ .bind(now_ms())
221+ .bind(user_id)
222+ .execute(&self.pool)
223+ .await
224+ .map_err(|e| map_conflict(e, "user", &[("email", "email")]))?
225+ .rows_affected();
226+ Ok(updated > 0)
227+ }
228+
181229 /// Set (or clear) a user's password hash.
182230 ///
183231 /// Passing `Some(hash)` sets the credential and clears the
crates/web/src/lib.rs +5 −0
@@ -146,6 +146,11 @@ pub fn build_router(state: AppState) -> Router {
146146 "/settings",
147147 get(pages::settings).post(pages::settings_submit),
148148 )
149+ .route("/settings/username", post(pages::account_username))
150+ .route("/settings/email", post(pages::account_email))
151+ .route("/settings/password", post(pages::account_password))
152+ .route("/settings/tokens", post(pages::token_create))
153+ .route("/settings/tokens/revoke", post(pages::token_revoke))
149154 .route("/settings/avatar", post(avatar::upload))
150155 .route("/settings/theme", get(pages::set_theme))
151156 .route("/avatar/{username}", get(avatar::show))
crates/web/src/pages.rs +350 −12
@@ -679,10 +679,11 @@ pub async fn settings(
679679 RequireUser(user): RequireUser,
680680 jar: CookieJar,
681681 uri: Uri,
682-) -> Response {
682+) -> AppResult<Response> {
683+ let tokens = state.store.tokens_by_user(&user.id).await?;
683684 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
684- let body = settings_body(&user, &chrome.csrf, None);
685- (jar, page(&chrome, "Settings", body)).into_response()
685+ let body = settings_body(&user, &chrome.csrf, &tokens, None);
686+ Ok((jar, page(&chrome, "Settings", body)).into_response())
686687 }
687688
688689 /// Profile settings form fields. Empty strings clear the corresponding column.
@@ -762,14 +763,267 @@ fn norm(s: &str) -> Option<String> {
762763 (!t.is_empty()).then(|| t.to_string())
763764 }
764765
765-/// Render the settings page: avatar, profile fields, and read-only account info.
766-fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup {
766+/// A single-field account form (username or email) with a CSRF token.
767+#[derive(Debug, Deserialize)]
768+pub struct AccountFieldForm {
769+ /// The new username.
770+ #[serde(default)]
771+ username: String,
772+ /// The new email.
773+ #[serde(default)]
774+ email: String,
775+ /// CSRF token.
776+ #[serde(rename = "_csrf")]
777+ csrf: String,
778+}
779+
780+/// `POST /settings/username` — change the viewer's username.
781+pub async fn account_username(
782+ State(state): State<AppState>,
783+ RequireUser(user): RequireUser,
784+ jar: CookieJar,
785+ headers: HeaderMap,
786+ Form(form): Form<AccountFieldForm>,
787+) -> Response {
788+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
789+ return AppError::Forbidden.into_response();
790+ }
791+ match state
792+ .store
793+ .update_username(&user.id, form.username.trim())
794+ .await
795+ {
796+ Ok(_) => Redirect::to("/settings").into_response(),
797+ Err(store::StoreError::Conflict { .. }) => {
798+ AppError::BadRequest("That username is already taken.".to_string()).into_response()
799+ }
800+ Err(store::StoreError::Name(_)) => {
801+ AppError::BadRequest("Invalid username.".to_string()).into_response()
802+ }
803+ Err(err) => AppError::from(err).into_response(),
804+ }
805+}
806+
807+/// `POST /settings/email` — change the viewer's email.
808+pub async fn account_email(
809+ State(state): State<AppState>,
810+ RequireUser(user): RequireUser,
811+ jar: CookieJar,
812+ headers: HeaderMap,
813+ Form(form): Form<AccountFieldForm>,
814+) -> Response {
815+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
816+ return AppError::Forbidden.into_response();
817+ }
818+ let email = form.email.trim();
819+ if !email.contains('@') {
820+ return AppError::BadRequest("Enter a valid email address.".to_string()).into_response();
821+ }
822+ match state.store.update_email(&user.id, email).await {
823+ Ok(_) => Redirect::to("/settings").into_response(),
824+ Err(store::StoreError::Conflict { .. }) => {
825+ AppError::BadRequest("That email is already in use.".to_string()).into_response()
826+ }
827+ Err(err) => AppError::from(err).into_response(),
828+ }
829+}
830+
831+/// The change-password form.
832+#[derive(Debug, Deserialize)]
833+pub struct PasswordForm {
834+ /// The current password (re-verified).
835+ #[serde(default)]
836+ current: String,
837+ /// The new password.
838+ #[serde(default)]
839+ new_password: String,
840+ /// Confirmation of the new password.
841+ #[serde(default)]
842+ confirm: String,
843+ /// CSRF token.
844+ #[serde(rename = "_csrf")]
845+ csrf: String,
846+}
847+
848+/// `POST /settings/password` — change the password after re-verifying the current
849+/// one; all other sessions are invalidated and this one is re-established.
850+pub async fn account_password(
851+ State(state): State<AppState>,
852+ RequireUser(user): RequireUser,
853+ jar: CookieJar,
854+ headers: HeaderMap,
855+ Form(form): Form<PasswordForm>,
856+) -> Response {
857+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
858+ return AppError::Forbidden.into_response();
859+ }
860+ if !auth::verify_password(&form.current, user.password_hash.as_deref()) {
861+ return AppError::BadRequest("Current password is incorrect.".to_string()).into_response();
862+ }
863+ if form.new_password != form.confirm {
864+ return AppError::BadRequest("New passwords do not match.".to_string()).into_response();
865+ }
866+ if form.new_password.len() < 8 {
867+ return AppError::BadRequest("New password must be at least 8 characters.".to_string())
868+ .into_response();
869+ }
870+ let result = async {
871+ let params = auth::HashParams {
872+ m_cost: state.config.auth.argon2_m_cost,
873+ t_cost: state.config.auth.argon2_t_cost,
874+ p_cost: state.config.auth.argon2_p_cost,
875+ };
876+ let hash = auth::hash_password(&form.new_password, params).map_err(AppError::internal)?;
877+ // set_password deletes every session (including this one); re-establish it.
878+ state.store.set_password(&user.id, Some(&hash)).await?;
879+ open_session(&state, &jar, &user, &headers).await
880+ }
881+ .await;
882+ match result {
883+ Ok(jar) => (jar, Redirect::to("/settings")).into_response(),
884+ Err(err) => err.into_response(),
885+ }
886+}
887+
888+/// The create-token form.
889+#[derive(Debug, Deserialize)]
890+pub struct TokenCreateForm {
891+ /// The token label.
892+ #[serde(default)]
893+ name: String,
894+ /// `repo:read` scope (checkbox present when granted).
895+ #[serde(default)]
896+ repo_read: Option<String>,
897+ /// `repo:write` scope.
898+ #[serde(default)]
899+ repo_write: Option<String>,
900+ /// `admin` scope.
901+ #[serde(default)]
902+ admin: Option<String>,
903+ /// CSRF token.
904+ #[serde(rename = "_csrf")]
905+ csrf: String,
906+}
907+
908+/// `POST /settings/tokens` — mint a new API token, shown once.
909+pub async fn token_create(
910+ State(state): State<AppState>,
911+ RequireUser(user): RequireUser,
912+ jar: CookieJar,
913+ headers: HeaderMap,
914+ Form(form): Form<TokenCreateForm>,
915+) -> Response {
916+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
917+ return AppError::Forbidden.into_response();
918+ }
919+ let name = form.name.trim();
920+ if name.is_empty() {
921+ return AppError::BadRequest("Token name is required.".to_string()).into_response();
922+ }
923+ let mut scopes: Vec<&str> = Vec::new();
924+ if form.repo_read.is_some() {
925+ scopes.push("repo:read");
926+ }
927+ if form.repo_write.is_some() {
928+ scopes.push("repo:write");
929+ }
930+ if form.admin.is_some() {
931+ scopes.push("admin");
932+ }
933+ if scopes.is_empty() {
934+ return AppError::BadRequest("Select at least one scope.".to_string()).into_response();
935+ }
936+ let scope_str = scopes.join(",");
937+
938+ let result = async {
939+ let parsed = auth::parse_scopes(&scope_str).map_err(AppError::internal)?;
940+ let canonical = auth::format_scopes(&parsed);
941+ let token = state
942+ .store
943+ .create_token(store::NewToken {
944+ user_id: user.id.clone(),
945+ name: name.to_string(),
946+ scopes: canonical.clone(),
947+ expires_at: None,
948+ })
949+ .await?;
950+ // Mint the JWT with a far-future expiry (the row's revoked_at enforces
951+ // revocation regardless).
952+ let now = now_ms().div_euclid(1000);
953+ let claims = auth::Claims {
954+ sub: user.id.clone(),
955+ jti: token.id.clone(),
956+ scopes: canonical,
957+ iat: now,
958+ exp: now.saturating_add(10 * 365 * 86_400),
959+ };
960+ let jwt = auth::mint_jwt(&state.secret, &claims).map_err(AppError::internal)?;
961+ Ok::<String, AppError>(jwt)
962+ }
963+ .await;
964+
965+ match result {
966+ Ok(jwt) => {
967+ let tokens = state
968+ .store
969+ .tokens_by_user(&user.id)
970+ .await
971+ .unwrap_or_default();
972+ let (jar, chrome) =
973+ build_chrome(&state, jar, Some(user.clone()), "/settings".to_string());
974+ let body = settings_body(&user, &chrome.csrf, &tokens, Some(&jwt));
975+ (jar, page(&chrome, "Settings", body)).into_response()
976+ }
977+ Err(err) => err.into_response(),
978+ }
979+}
980+
981+/// The revoke-token form.
982+#[derive(Debug, Deserialize)]
983+pub struct TokenRevokeForm {
984+ /// The token id.
985+ #[serde(default)]
986+ id: String,
987+ /// CSRF token.
988+ #[serde(rename = "_csrf")]
989+ csrf: String,
990+}
991+
992+/// `POST /settings/tokens/revoke` — revoke one of the viewer's tokens.
993+pub async fn token_revoke(
994+ State(state): State<AppState>,
995+ RequireUser(user): RequireUser,
996+ jar: CookieJar,
997+ headers: HeaderMap,
998+ Form(form): Form<TokenRevokeForm>,
999+) -> Response {
1000+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1001+ return AppError::Forbidden.into_response();
1002+ }
1003+ // Only revoke a token that belongs to the viewer.
1004+ let owned = state
1005+ .store
1006+ .token_by_id(&form.id)
1007+ .await
1008+ .ok()
1009+ .flatten()
1010+ .is_some_and(|t| t.user_id == user.id);
1011+ if owned {
1012+ let _ = state.store.revoke_token(&form.id).await;
1013+ }
1014+ Redirect::to("/settings").into_response()
1015+}
1016+
1017+/// Render the settings page: avatar, profile, account credentials, and tokens.
1018+fn settings_body(
1019+ user: &User,
1020+ csrf: &str,
1021+ tokens: &[model::ApiToken],
1022+ new_token: Option<&str>,
1023+) -> Markup {
7671024 let val = |v: &Option<String>| v.clone().unwrap_or_default();
7681025 html! {
7691026 h1 { "Settings" }
770- @if let Some(msg) = notice {
771- div class="notice notice-success" { (msg) }
772- }
7731027 section class="listing" {
7741028 h2 { "Avatar" }
7751029 div class="card avatar-settings" {
@@ -816,10 +1070,94 @@ fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup {
8161070 section class="listing" {
8171071 h2 { "Account" }
8181072 div class="card" {
819- table class="list" {
820- tr { th { "Username" } td { (user.username) } }
821- tr { th { "Email" } td { (user.email) } }
822- tr { th { "Administrator" } td { (if user.is_admin { "yes" } else { "no" }) } }
1073+ form method="post" action="/settings/username" {
1074+ input type="hidden" name="_csrf" value=(csrf);
1075+ label for="username" { "Username" }
1076+ input type="text" id="username" name="username" value=(user.username) required;
1077+ button class="btn" type="submit" { "Change username" }
1078+ }
1079+ form method="post" action="/settings/email" {
1080+ input type="hidden" name="_csrf" value=(csrf);
1081+ label for="email" { "Email" }
1082+ input type="email" id="email" name="email" value=(user.email) required;
1083+ button class="btn" type="submit" { "Change email" }
1084+ }
1085+ p class="muted field-hint" {
1086+ "Administrator: " (if user.is_admin { "yes" } else { "no" })
1087+ }
1088+ }
1089+ }
1090+ section class="listing" {
1091+ h2 { "Password" }
1092+ div class="card" {
1093+ form method="post" action="/settings/password" {
1094+ input type="hidden" name="_csrf" value=(csrf);
1095+ label for="current" { "Current password" }
1096+ input type="password" id="current" name="current"
1097+ autocomplete="current-password" required;
1098+ label for="new_password" { "New password" }
1099+ input type="password" id="new_password" name="new_password"
1100+ autocomplete="new-password" required minlength="8";
1101+ label for="confirm" { "Confirm new password" }
1102+ input type="password" id="confirm" name="confirm"
1103+ autocomplete="new-password" required;
1104+ button class="btn btn-primary" type="submit" { "Change password" }
1105+ }
1106+ }
1107+ }
1108+ (tokens_section(csrf, tokens, new_token))
1109+ }
1110+}
1111+
1112+/// The API tokens settings section.
1113+fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup {
1114+ html! {
1115+ section class="listing" {
1116+ h2 { "API tokens" }
1117+ div class="card" {
1118+ @if let Some(secret) = new_token {
1119+ div class="notice notice-success" {
1120+ p { strong { "New token created." } " Copy it now — it is not shown again." }
1121+ pre class="token-secret" { code { (secret) } }
1122+ }
1123+ }
1124+ @if tokens.is_empty() {
1125+ p class="muted" { "No API tokens yet." }
1126+ } @else {
1127+ ul class="entry-list token-list" {
1128+ @for t in tokens {
1129+ li class="entry-row" {
1130+ div class="entry-row-body" {
1131+ span class="entry-row-title" { (t.name) }
1132+ div class="entry-row-meta muted" {
1133+ span { (t.scopes) }
1134+ @if t.revoked_at.is_some() { span class="badge badge-private" { "revoked" } }
1135+ }
1136+ }
1137+ div class="entry-row-actions" {
1138+ @if t.revoked_at.is_none() {
1139+ form method="post" action="/settings/tokens/revoke" {
1140+ input type="hidden" name="_csrf" value=(csrf);
1141+ input type="hidden" name="id" value=(t.id);
1142+ button class="btn btn-danger" type="submit" { "Revoke" }
1143+ }
1144+ }
1145+ }
1146+ }
1147+ }
1148+ }
1149+ }
1150+ form class="token-add" method="post" action="/settings/tokens" {
1151+ input type="hidden" name="_csrf" value=(csrf);
1152+ label for="token_name" { "New token" }
1153+ input type="text" id="token_name" name="name" placeholder="Token name" required;
1154+ fieldset class="token-scopes" {
1155+ legend { "Scopes" }
1156+ label class="checkbox" { input type="checkbox" name="repo_read" value="1" checked; " repo:read" }
1157+ label class="checkbox" { input type="checkbox" name="repo_write" value="1"; " repo:write" }
1158+ label class="checkbox" { input type="checkbox" name="admin" value="1"; " admin" }
1159+ }
1160+ button class="btn btn-primary" type="submit" { "Create token" }
8231161 }
8241162 }
8251163 }
crates/web/src/tests.rs +17 −0
@@ -452,6 +452,23 @@ async fn new_repo_page_requires_auth_and_renders() {
452452 }
453453
454454 #[tokio::test]
455+async fn settings_page_shows_account_and_tokens() {
456+ let (_state, app) = app().await;
457+ let cookie = login(&app).await;
458+ let req = Request::builder()
459+ .uri("/settings")
460+ .header(header::COOKIE, cookie)
461+ .body(Body::empty())
462+ .unwrap();
463+ let res = app.oneshot(req).await.unwrap();
464+ assert_eq!(res.status(), StatusCode::OK);
465+ let html = body_string(res).await;
466+ assert!(html.contains("Change username"), "account section");
467+ assert!(html.contains("Change password"), "password section");
468+ assert!(html.contains("API tokens"), "tokens section");
469+}
470+
471+#[tokio::test]
455472 async fn settings_requires_authentication() {
456473 let (_state, app) = app().await;
457474 let res = app.oneshot(get("/settings")).await.unwrap();