fix email uniqueness logic for new signups

This commit is contained in:
Brent Schroeter 2026-01-20 07:25:45 +00:00
parent fc8e3d6b99
commit b701270d88
5 changed files with 60 additions and 20 deletions

View file

@ -0,0 +1,2 @@
drop index if exists users_email_idx;
create index on users (email);

View file

@ -0,0 +1,2 @@
drop index if exists users_email_idx;
create unique index on users (email);

View file

@ -110,29 +110,55 @@ returning id, uid, email
.await? .await?
{ {
user user
} else if uid.is_some() {
// Conflict should have been on at least `uid`, meaning that the
// user already exists and its fields are fully populated.
query_as!(User, "select id, uid, email from users where uid = $1", uid)
.fetch_one(app_db.get_conn())
.await?
} else { } else {
// Conflict must have been on `email`, meaning that the user // Conflict may have been on either or both of `email` and
// already exists, though its `uid` may not be up to date. Use // `uid`.
// `COALESCE()` on the `uid` value to ensure that it is not //
// inadvertently removed if present. // If the conflict was on `email`, we want to ensure that `uid`
query_as!( // is updated if and only if it is currently null. This can be
// accomplished using `COALESCE()` with the current `uid` value
// as the first argument. The parameterized value will only be
// used if the current value is null.
//
// If the conflict was on `uid`, we merely want to return the
// record with the matching `uid`.
//
// If both fields conflicted then the `UPDATE` query will be
// sufficient, but if the operation is using a different email
// address than the one already stored then we will need one
// final `SELECT` query to guarantee that we obtain a result.
// Assuming that the caller queries by `uid` prior to starting
// the upsert, this last query should only be hit when two HTTP
// requests are running the auth flow concurrently.
if let Some(user) = query_as!(
User, User,
" "
update users update users
set uid = coalesce($1, uid) set uid = coalesce(uid, $1)
where email = lower($1) where email = lower($2)
returning id, uid, email returning id, uid, email
", ",
uid,
email, email,
) )
.fetch_one(app_db.get_conn()) .fetch_optional(app_db.get_conn())
.await? .await?
{
user
} else {
User::with_uid(
// TODO: This should hold true *unless* the database is
// "tampered with" by an outside actor (such as a
// developer performing manual maintenance). While such
// an edge case is beyond the scope of the server's
// responsibilities, panicking may be considered an
// overreaction.
uid.expect("uid must be non-null to cause a database conflict"),
)
.fetch_optional(app_db)
.await?
.ok_or(sqlx::Error::RowNotFound)?
}
}, },
) )
} }

View file

@ -7,6 +7,7 @@ use phono_backends::{escape_identifier, pg_database::PgDatabase, rolnames::ROLE_
use phono_models::{ use phono_models::{
accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor}, accessors::{Accessor as _, Actor, workspace::WorkspaceAccessor},
user::User, user::User,
workspace_user_perm::WorkspaceMembership,
}; };
use serde::Deserialize; use serde::Deserialize;
use sqlx::query; use sqlx::query;
@ -84,7 +85,7 @@ pub(super) async fn post(
.await? .await?
.datname; .datname;
query(&format!( query(&format!(
"grant connect on database {db_name_esc} to {rolname}", "grant connect on database {db_name_esc} to {rolname} with grant option",
db_name_esc = escape_identifier(&db_name), db_name_esc = escape_identifier(&db_name),
rolname = escape_identifier(&format!( rolname = escape_identifier(&format!(
"{ROLE_PREFIX_USER}{user_id}", "{ROLE_PREFIX_USER}{user_id}",
@ -93,6 +94,20 @@ pub(super) async fn post(
)) ))
.execute(root_client.get_conn()) .execute(root_client.get_conn())
.await?; .await?;
query(&format!(
"grant usage, create on schema phono to {rolname} with grant option",
rolname = escape_identifier(&format!(
"{ROLE_PREFIX_USER}{user_id}",
user_id = target_user.id.simple()
))
))
.execute(root_client.get_conn())
.await?;
WorkspaceMembership::upsert()
.workspace_id(workspace_id)
.user_id(target_user.id)
.execute(&mut app_db)
.await?;
Ok(navigator Ok(navigator
.workspace_page() .workspace_page()

View file

@ -33,10 +33,5 @@
{% endfor %} {% endfor %}
</menu> </menu>
</section> </section>
<section class="workspace-nav__section">
<div class="workspace-nav__heading">
<h1>Shared With Me</h1>
</div>
</section>
</nav> </nav>
{% endblock %} {% endblock %}