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 {
359 margin: 0.25rem 0 0.5rem;359 margin: 0.25rem 0 0.5rem;
360 font-size: 0.85em;360 font-size: 0.85em;
361}361}
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}
385label.checkbox {
386 display: inline-flex;
387 align-items: center;
388 gap: 0.3rem;
389 margin: 0 1rem 0 0;
390 font-weight: 400;
391}
392label.checkbox input {
393 width: auto;
394}
362/* Link slots stack full-width; the Add button sits beside the last one. */395/* Link slots stack full-width; the Add button sits beside the last one. */
363.link-fields {396.link-fields {
364 display: flex;397 display: flex;
crates/store/src/users.rs +48 −0
@@ -178,6 +178,54 @@ impl Store {
178 .map_err(Into::into)178 .map_err(Into::into)
179 }179 }
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
181 /// Set (or clear) a user's password hash.229 /// Set (or clear) a user's password hash.
182 ///230 ///
183 /// Passing `Some(hash)` sets the credential and clears the231 /// 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 {
146 "/settings",146 "/settings",
147 get(pages::settings).post(pages::settings_submit),147 get(pages::settings).post(pages::settings_submit),
148 )148 )
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))
149 .route("/settings/avatar", post(avatar::upload))154 .route("/settings/avatar", post(avatar::upload))
150 .route("/settings/theme", get(pages::set_theme))155 .route("/settings/theme", get(pages::set_theme))
151 .route("/avatar/{username}", get(avatar::show))156 .route("/avatar/{username}", get(avatar::show))
crates/web/src/pages.rs +350 −12
@@ -679,10 +679,11 @@ pub async fn settings(
679 RequireUser(user): RequireUser,679 RequireUser(user): RequireUser,
680 jar: CookieJar,680 jar: CookieJar,
681 uri: Uri,681 uri: Uri,
682) -> Response {682) -> AppResult<Response> {
683 let tokens = state.store.tokens_by_user(&user.id).await?;
683 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());684 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
684 let body = settings_body(&user, &chrome.csrf, None);685 let body = settings_body(&user, &chrome.csrf, &tokens, None);
685 (jar, page(&chrome, "Settings", body)).into_response()686 Ok((jar, page(&chrome, "Settings", body)).into_response())
686}687}
687688
688/// Profile settings form fields. Empty strings clear the corresponding column.689/// Profile settings form fields. Empty strings clear the corresponding column.
@@ -762,14 +763,267 @@ fn norm(s: &str) -> Option<String> {
762 (!t.is_empty()).then(|| t.to_string())763 (!t.is_empty()).then(|| t.to_string())
763}764}
764765
765/// Render the settings page: avatar, profile fields, and read-only account info.766/// A single-field account form (username or email) with a CSRF token.
766fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup {767#[derive(Debug, Deserialize)]
768pub 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.
781pub 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.
808pub 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)]
833pub 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.
850pub 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)]
890pub 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.
909pub 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)]
983pub 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.
993pub 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.
1018fn settings_body(
1019 user: &User,
1020 csrf: &str,
1021 tokens: &[model::ApiToken],
1022 new_token: Option<&str>,
1023) -> Markup {
767 let val = |v: &Option<String>| v.clone().unwrap_or_default();1024 let val = |v: &Option<String>| v.clone().unwrap_or_default();
768 html! {1025 html! {
769 h1 { "Settings" }1026 h1 { "Settings" }
770 @if let Some(msg) = notice {
771 div class="notice notice-success" { (msg) }
772 }
773 section class="listing" {1027 section class="listing" {
774 h2 { "Avatar" }1028 h2 { "Avatar" }
775 div class="card avatar-settings" {1029 div class="card avatar-settings" {
@@ -816,10 +1070,94 @@ fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup {
816 section class="listing" {1070 section class="listing" {
817 h2 { "Account" }1071 h2 { "Account" }
818 div class="card" {1072 div class="card" {
819 table class="list" {1073 form method="post" action="/settings/username" {
820 tr { th { "Username" } td { (user.username) } }1074 input type="hidden" name="_csrf" value=(csrf);
821 tr { th { "Email" } td { (user.email) } }1075 label for="username" { "Username" }
822 tr { th { "Administrator" } td { (if user.is_admin { "yes" } else { "no" }) } }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.
1113fn 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" }
823 }1161 }
824 }1162 }
825 }1163 }
crates/web/src/tests.rs +17 −0
@@ -452,6 +452,23 @@ async fn new_repo_page_requires_auth_and_renders() {
452}452}
453453
454#[tokio::test]454#[tokio::test]
455async 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]
455async fn settings_requires_authentication() {472async fn settings_requires_authentication() {
456 let (_state, app) = app().await;473 let (_state, app) = app().await;
457 let res = app.oneshot(get("/settings")).await.unwrap();474 let res = app.oneshot(get("/settings")).await.unwrap();