Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions crates/api/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ const OTP_TTL_MINUTES: i64 = 10;
pub struct UserView {
pub id: Uuid,
pub email: String,
pub username: Option<String>,
}

#[derive(Debug, Deserialize, Default)]
pub struct UpdateUsernameRequest {
pub username: Option<String>,
}

/// JWT claims.
Expand Down Expand Up @@ -252,6 +258,7 @@ pub async fn verify_email(
user: UserView {
id: user.id,
email: user.email,
username: user.username,
},
}))
}
Expand Down Expand Up @@ -354,6 +361,7 @@ pub async fn login(
user: UserView {
id: user.id,
email: user.email,
username: user.username,
},
})
.map_err(|_| ApiError::Internal)?,
Expand Down Expand Up @@ -416,6 +424,7 @@ pub async fn refresh(
user: UserView {
id: user.id,
email: user.email,
username: user.username,
},
}))
}
Expand All @@ -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<AppState>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult<Json<Envelope<UserView>>> {
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,
}))
}

Expand Down Expand Up @@ -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<String>) -> Result<String, ApiError> {
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<String, ApiError> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
Expand Down
2 changes: 1 addition & 1 deletion crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions crates/store/migrations/0020_username.sql
Original file line number Diff line number Diff line change
@@ -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));
13 changes: 13 additions & 0 deletions crates/store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<User, StoreError> {
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> {
Expand Down
2 changes: 2 additions & 0 deletions crates/store/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// argon2id PHC hash — never returned to clients.
pub password_hash: String,
/// Null until the signup/login OTP is verified.
Expand Down
6 changes: 3 additions & 3 deletions crates/store/tests/store_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

Expand Down
Loading