From 922cb2bae9cea33591c41a42c260134b85aca173 Mon Sep 17 00:00:00 2001 From: macnelson9 Date: Tue, 11 Aug 2026 15:25:25 +0100 Subject: [PATCH 1/2] feat: add optional display username to dashboard users Adds a nullable, case-insensitively-unique username column and threads it through UserView on signup verification, login, refresh, and /me. Adds PATCH /v1/auth/me to set it (3-20 chars, alphanumeric/underscore/ hyphen, 400 on duplicate). Backs the frontend's dashboard greeting and Settings page. --- crates/api/src/auth.rs | 61 +++++++++++++++++++++++ crates/api/src/lib.rs | 2 +- crates/store/migrations/0020_username.sql | 4 ++ crates/store/src/lib.rs | 13 +++++ crates/store/src/models.rs | 2 + 5 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 crates/store/migrations/0020_username.sql diff --git a/crates/api/src/auth.rs b/crates/api/src/auth.rs index b85e38d..f7a808b 100644 --- a/crates/api/src/auth.rs +++ b/crates/api/src/auth.rs @@ -93,6 +93,12 @@ const OTP_TTL_MINUTES: i64 = 10; pub struct UserView { pub id: Uuid, pub email: String, + pub username: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct UpdateUsernameRequest { + pub username: Option, } /// JWT claims. @@ -252,6 +258,7 @@ pub async fn verify_email( user: UserView { id: user.id, email: user.email, + username: user.username, }, })) } @@ -354,6 +361,7 @@ pub async fn login( user: UserView { id: user.id, email: user.email, + username: user.username, }, }) .map_err(|_| ApiError::Internal)?, @@ -416,6 +424,7 @@ pub async fn refresh( user: UserView { id: user.id, email: user.email, + username: user.username, }, })) } @@ -435,6 +444,35 @@ pub async fn me( Ok(Envelope::ok(UserView { id: user.id, email: user.email, + username: user.username, + })) +} + +/// `PATCH /v1/auth/me` — set the authenticated user's display username. +pub async fn update_username( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> ApiResult>> { + let user_id = authenticate(&headers, &state).await?; + let req: UpdateUsernameRequest = parse_optional(&body)?; + let username = validate_username(req.username)?; + + let user = state + .store() + .update_username(user_id, &username) + .await + .map_err(|e| match e { + octo_store::StoreError::Conflict => { + ApiError::BadRequest("username already taken".into()) + } + _ => ApiError::Internal, + })?; + + Ok(Envelope::ok(UserView { + id: user.id, + email: user.email, + username: user.username, })) } @@ -501,6 +539,29 @@ fn validate(creds: Credentials) -> Result<(String, String), ApiError> { Ok((email, password)) } +/// 3–20 chars, ASCII letters/digits/underscore/hyphen only. Case is preserved for display; +/// uniqueness is enforced case-insensitively by `users_username_unique_idx`. +fn validate_username(input: Option) -> Result { + let username = input + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty()) + .ok_or_else(|| ApiError::BadRequest("username is required".into()))?; + if username.len() < 3 || username.len() > 20 { + return Err(ApiError::BadRequest( + "username must be 3-20 characters".into(), + )); + } + if !username + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(ApiError::BadRequest( + "username may only contain letters, numbers, underscores, and hyphens".into(), + )); + } + Ok(username) +} + fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); Argon2::default() diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index dc6f5fb..a41dbf8 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -47,7 +47,7 @@ pub fn build_router(state: AppState) -> Router { .route("/v1/auth/resend-otp", post(auth::resend_otp)) .route("/v1/auth/login", post(auth::login)) .route("/v1/auth/refresh", post(auth::refresh)) - .route("/v1/auth/me", get(auth::me)) + .route("/v1/auth/me", get(auth::me).patch(auth::update_username)) .route("/v1/auth/logout", post(auth::logout)) .route("/v1/audit-logs", get(routes::audit::list_audit_logs)) .route( diff --git a/crates/store/migrations/0020_username.sql b/crates/store/migrations/0020_username.sql new file mode 100644 index 0000000..a0a336c --- /dev/null +++ b/crates/store/migrations/0020_username.sql @@ -0,0 +1,4 @@ +-- Optional display username, distinct from email. Null until the user sets one (that write path +-- is a separate change); unique case-insensitively so "Tosin" and "tosin" can't both be taken. +ALTER TABLE users ADD COLUMN username TEXT; +CREATE UNIQUE INDEX users_username_unique_idx ON users (lower(username)); diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index bb7b584..97f981a 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -114,6 +114,19 @@ impl Store { .map_err(StoreError::from_sqlx_conflict) } + /// Set a user's display username. Returns [`StoreError::Conflict`] if another user already + /// has it (compared case-insensitively, per the `users_username_unique_idx` index). + pub async fn update_username(&self, user_id: Uuid, username: &str) -> Result { + sqlx::query_as::<_, User>( + "UPDATE users SET username = $2, updated_at = now() WHERE id = $1 RETURNING *", + ) + .bind(user_id) + .bind(username) + .fetch_one(&self.pool) + .await + .map_err(StoreError::from_sqlx_conflict) + } + /// Delete a user outright. Only safe pre-verification — used to roll back a signup whose /// OTP email never went out, so the email isn't stuck as "already registered" forever. pub async fn delete_unverified_user(&self, user_id: Uuid) -> Result<(), StoreError> { diff --git a/crates/store/src/models.rs b/crates/store/src/models.rs index bf4354c..8f8f8a5 100644 --- a/crates/store/src/models.rs +++ b/crates/store/src/models.rs @@ -105,6 +105,8 @@ pub struct Withdrawal { pub struct User { pub id: Uuid, pub email: String, + /// Optional display name, set by the user. Null until they choose one. + pub username: Option, /// argon2id PHC hash — never returned to clients. pub password_hash: String, /// Null until the signup/login OTP is verified. From c78cf1d0c52d14506f22923746307ec381909df4 Mon Sep 17 00:00:00 2001 From: macnelson9 Date: Wed, 12 Aug 2026 00:04:53 +0100 Subject: [PATCH 2/2] test: update expected migration set for 0020_username migrate_applies_exactly_the_expected_version_set hardcoded the known migration versions; it needed the new username migration added to its expected list. --- crates/store/tests/store_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/store/tests/store_tests.rs b/crates/store/tests/store_tests.rs index e2f1be1..9b047f6 100644 --- a/crates/store/tests/store_tests.rs +++ b/crates/store/tests/store_tests.rs @@ -850,13 +850,13 @@ async fn migrate_applies_exactly_the_expected_version_set() { .expect("query _sqlx_migrations"); versions.sort_unstable(); - // One version per file under crates/store/migrations/, 0001_init.sql .. 0019. + // One version per file under crates/store/migrations/, 0001_init.sql .. 0020. // Guards against silent version collisions — sqlx keys migrations by version, so a repeated // number means only one of the colliding pair actually ran. assert_eq!( versions, - vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], - "expected exactly the nineteen known migrations to be recorded as applied" + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], + "expected exactly the twenty known migrations to be recorded as applied" ); }