Skip to content
Merged
8 changes: 8 additions & 0 deletions migrations/036_drop_user_display_name.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- users.handle (035) is the sole human-facing identifier. display_name was a
-- second name for the same person and a weaker one: it defaulted to
-- split_part(email, '@', 1), so it leaked the email local part by construction.
--
-- DEPLOY WARNING: irreversible, no down-migration. The old server still
-- reads/writes display_name, so it cannot keep serving once this lands —
-- no rolling restart across this migration.
ALTER TABLE users DROP COLUMN IF EXISTS display_name;
1 change: 0 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,6 @@ async fn main() {
let protected = Router::new()
.route("/v1/auth/account", delete(routes::auth::delete_account))
.route("/v1/auth/me", get(routes::auth::get_me))
.route("/v1/auth/display-name", put(routes::auth::update_display_name))
.route("/v1/auth/email", put(routes::auth::update_email))
.route("/v1/auth/password", put(routes::auth::update_password))
.route(
Expand Down
3 changes: 3 additions & 0 deletions src/models/team.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ pub struct TeamRole {
pub struct TeamMember {
pub team_id: Uuid,
pub user_id: Uuid,
/// ALIAS for pre-0.26 clients. Value is the handle; there is no stored
/// `display_name`. Delete this field in 0.27, and never repopulate it.
pub display_name: String,
pub handle: String,
pub public_key: String,
/// The inviter's handle. The field name is the alias, the value is not.
pub invited_by_display_name: Option<String>,
pub joined_at: DateTime<Utc>,
pub role_ids: Vec<Uuid>,
Expand Down
4 changes: 2 additions & 2 deletions src/routes/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1803,8 +1803,8 @@ mod admin_handler_tests {
.await
.expect("generate handle");
sqlx::query(
"INSERT INTO users (id, email, account_id, auth_hash, public_key, display_name, last_seen_on, handle)
VALUES ($1, $2, $3, 'test-hash', 'test-pubkey', 'Test User',
"INSERT INTO users (id, email, account_id, auth_hash, public_key, last_seen_on, handle)
VALUES ($1, $2, $3, 'test-hash', 'test-pubkey',
CASE WHEN $4::int IS NULL THEN NULL ELSE current_date - $4::int END, $5)",
)
.bind(id)
Expand Down
4 changes: 2 additions & 2 deletions src/routes/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ pub async fn list_audit_logs(
let logs = sqlx::query_as::<_, AuditLogRow>(
r#"SELECT
al.id, al.team_id, al.vault_id, al.actor_id,
u.display_name AS actor_name,
u.handle AS actor_name,
al.action, al.source, al.target_type, al.target_id, al.target_name,
al.metadata, al.ip_address::text AS ip_address, al.created_at
FROM audit_logs al
Expand Down Expand Up @@ -219,7 +219,7 @@ pub async fn export_audit_logs(
let logs = sqlx::query_as::<_, AuditLogRow>(
r#"SELECT
al.id, al.team_id, al.vault_id, al.actor_id,
u.display_name AS actor_name,
u.handle AS actor_name,
al.action, al.source, al.target_type, al.target_id, al.target_name,
al.metadata, al.ip_address::text AS ip_address, al.created_at
FROM audit_logs al
Expand Down
72 changes: 32 additions & 40 deletions src/routes/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,8 @@ pub async fn register(
})?;

let row = sqlx::query_as::<_, (Uuid,)>(
"INSERT INTO users (email, display_name, account_id, auth_hash, public_key, wrapped_user_secrets, subscription_tier, trial_ends_at, handle)
VALUES ($1, split_part($1, '@', 1), $2, $3, $4, $5, $6, $7, $8) RETURNING id",
"INSERT INTO users (email, account_id, auth_hash, public_key, wrapped_user_secrets, subscription_tier, trial_ends_at, handle)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id",
)
.bind(&email)
.bind(body.account_id)
Expand Down Expand Up @@ -574,6 +574,7 @@ pub async fn resend_verification_email(
#[derive(Serialize)]
pub struct MeResponse {
pub email: String,
/// ALIAS for pre-0.26 clients. Value is the handle. Delete in 0.27.
pub display_name: String,
pub account_id: Uuid,
pub tier: String,
Expand All @@ -585,24 +586,21 @@ pub struct MeResponse {
pub allow_stranger_invites: bool,
}

pub async fn get_me(
State(pool): State<PgPool>,
axum::Extension(auth): axum::Extension<AuthUser>,
) -> Result<Json<MeResponse>, StatusCode> {
pub(crate) async fn fetch_me_inner(pool: &PgPool, user_id: Uuid) -> Result<MeResponse, StatusCode> {
let row = sqlx::query_as::<_, (String, String, Uuid, Option<String>, String, bool, bool)>(
"SELECT email, display_name, account_id, wrapped_user_secrets, handle, handle_is_custom, allow_stranger_invites FROM users WHERE id = $1",
"SELECT email, handle AS display_name, account_id, wrapped_user_secrets, handle, handle_is_custom, allow_stranger_invites FROM users WHERE id = $1",
)
.bind(auth.0)
.fetch_one(&pool)
.bind(user_id)
.fetch_one(pool)
.await
.map_err(|e| {
error!(error = %e, user_id = %auth.0, "Failed to fetch user in get_me");
error!(error = %e, user_id = %user_id, "Failed to fetch user in get_me");
StatusCode::INTERNAL_SERVER_ERROR
})?;

let tier = fetch_tier(&pool, auth.0).await?;
let tier = fetch_tier(pool, user_id).await?;

Ok(Json(MeResponse {
Ok(MeResponse {
email: row.0,
display_name: row.1,
account_id: row.2,
Expand All @@ -613,38 +611,14 @@ pub async fn get_me(
handle: row.4,
handle_is_custom: row.5,
allow_stranger_invites: row.6,
}))
}

// ─── Update display name ──────────────────────────────────────────────────────

#[derive(Deserialize)]
pub struct UpdateDisplayNameRequest {
pub display_name: String,
})
}

pub async fn update_display_name(
pub async fn get_me(
State(pool): State<PgPool>,
axum::Extension(auth): axum::Extension<AuthUser>,
Json(body): Json<UpdateDisplayNameRequest>,
) -> Result<StatusCode, StatusCode> {
let display_name = body.display_name.trim().to_string();
if display_name.is_empty() || display_name.len() > 50 {
return Err(StatusCode::UNPROCESSABLE_ENTITY);
}

sqlx::query("UPDATE users SET display_name = $1 WHERE id = $2")
.bind(&display_name)
.bind(auth.0)
.execute(&pool)
.await
.map_err(|e| {
error!(error = %e, user_id = %auth.0, "Failed to update display name");
StatusCode::INTERNAL_SERVER_ERROR
})?;

info!(user_id = %auth.0, display_name = %display_name, "Display name updated");
Ok(StatusCode::NO_CONTENT)
) -> Result<Json<MeResponse>, StatusCode> {
Ok(Json(fetch_me_inner(&pool, auth.0).await?))
}

// ─── Update email ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1357,4 +1331,22 @@ mod handler_tests {
Ok(_) => panic!("expected NOT_FOUND for soft-deleted account"),
}
}

#[tokio::test]
async fn me_reports_the_handle_under_both_names() {
let pool = test_pool_or_skip!();
let user = crate::test_support::seed_user(&pool).await;
let handle = crate::test_support::unique_handle("merry-quartz");
sqlx::query("UPDATE users SET handle = $1 WHERE id = $2")
.bind(&handle)
.bind(user)
.execute(&pool)
.await
.unwrap();

let me = fetch_me_inner(&pool, user).await.unwrap();
assert_eq!(me.handle, handle);
// ALIAS for pre-0.26 clients.
assert_eq!(me.display_name, handle);
}
}
4 changes: 2 additions & 2 deletions src/routes/invitations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub async fn get_invitation(
axum::extract::Path(token): axum::extract::Path<String>,
) -> Result<Json<InvitationDetails>, StatusCode> {
let row = sqlx::query_as::<_, (String, Option<String>, String, chrono::DateTime<chrono::Utc>)>(
r#"SELECT t.name, u.display_name, pi.role, pi.expires_at
r#"SELECT t.name, u.handle, pi.role, pi.expires_at
FROM pending_invitations pi
JOIN teams t ON t.id = pi.team_id
LEFT JOIN users u ON u.id = pi.invited_by
Expand Down Expand Up @@ -182,7 +182,7 @@ pub async fn list_my_pending_invitations(
axum::Extension(auth): axum::Extension<AuthUser>,
) -> Result<Json<Vec<MyPendingInvitation>>, StatusCode> {
let rows = sqlx::query_as::<_, (Uuid, Uuid, String, Option<String>, String, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>(
r#"SELECT pi.id, pi.team_id, t.name, u.display_name, pi.role, pi.created_at, pi.expires_at
r#"SELECT pi.id, pi.team_id, t.name, u.handle, pi.role, pi.created_at, pi.expires_at
FROM pending_invitations pi
JOIN teams t ON t.id = pi.team_id
LEFT JOIN users u ON u.id = pi.invited_by
Expand Down
Loading