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
1 change: 1 addition & 0 deletions migrations/066_pending_invitation_role_ids.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE pending_invitations ADD COLUMN role_ids UUID[] NOT NULL DEFAULT '{}';
23 changes: 15 additions & 8 deletions src/routes/invitations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub async fn get_invitation(

// ─── Admitting a member ───────────────────────────────────────────────────────

/// The membership row plus its builtin role, written the same way by all three
/// The membership row plus its roles, written the same way by all three
/// acceptance paths: the link token, the in-app pending invite, and the
/// auto-accept at registration.
///
Expand All @@ -66,6 +66,7 @@ pub(crate) async fn admit_member(
user_id: Uuid,
invited_by: Option<Uuid>,
role: &str,
role_ids: &[Uuid],
member_name: Option<&str>,
) -> Result<(), StatusCode> {
sqlx::query(
Expand All @@ -86,12 +87,16 @@ pub(crate) async fn admit_member(
sqlx::query(
r#"INSERT INTO team_member_roles (team_id, user_id, role_id)
SELECT $1, $2, tr.id FROM team_roles tr
WHERE tr.team_id = $1 AND tr.name = $3 AND tr.is_builtin = TRUE
WHERE tr.team_id = $1
AND (tr.id = ANY($4)
OR (tr.name = $3 AND tr.is_builtin
AND NOT EXISTS (SELECT 1 FROM team_roles kept WHERE kept.team_id = $1 AND kept.id = ANY($4))))
ON CONFLICT DO NOTHING"#,
)
.bind(team_id)
.bind(user_id)
.bind(role)
.bind(role_ids)
.execute(&mut *conn)
.await
.map_err(|e| {
Expand All @@ -112,11 +117,12 @@ pub(crate) struct PendingInvitation {
team_id: Uuid,
email: String,
role: String,
role_ids: Vec<Uuid>,
invited_by: Option<Uuid>,
member_name: Option<String>,
}

pub(crate) const PENDING_INVITATION_COLUMNS: &str = "id, team_id, email, role, invited_by, member_name";
pub(crate) const PENDING_INVITATION_COLUMNS: &str = "id, team_id, email, role, role_ids, invited_by, member_name";

/// Every acceptance path ends here, so none can skip the audit row or the team fan-out.
pub(crate) async fn accept_pending_invitation(
Expand All @@ -138,6 +144,7 @@ pub(crate) async fn accept_pending_invitation(
user_id,
invitation.invited_by,
&invitation.role,
&invitation.role_ids,
invitation.member_name.as_deref(),
)
.await?;
Expand All @@ -163,7 +170,7 @@ pub(crate) async fn accept_pending_invitation(
invitation.team_id,
user_id,
user_email,
json!({ "role": invitation.role, "via": via }),
json!({ "role": invitation.role, "role_ids": invitation.role_ids, "via": via }),
)
.await;
Ok(())
Expand Down Expand Up @@ -682,7 +689,7 @@ mod admit_tests {
let invitee = seed_user(&pool).await;

let mut conn = pool.acquire().await.unwrap();
admit_member(&mut conn, team, invitee, Some(owner), "member", None)
admit_member(&mut conn, team, invitee, Some(owner), "member", &[], None)
.await
.unwrap();
drop(conn);
Expand Down Expand Up @@ -714,18 +721,18 @@ mod admit_tests {

let mut conn = pool.acquire().await.unwrap();
// A link-only invite carries no inviter; a later accept fills it in.
admit_member(&mut conn, team, invitee, None, "member", None)
admit_member(&mut conn, team, invitee, None, "member", &[], None)
.await
.unwrap();
assert_eq!(inviter_of(&pool, team, invitee).await, None);

admit_member(&mut conn, team, invitee, Some(owner), "member", None)
admit_member(&mut conn, team, invitee, Some(owner), "member", &[], None)
.await
.unwrap();
assert_eq!(inviter_of(&pool, team, invitee).await, Some(owner));

// …and a second invitation cannot claim credit for a member already in.
admit_member(&mut conn, team, invitee, Some(other), "member", None)
admit_member(&mut conn, team, invitee, Some(other), "member", &[], None)
.await
.unwrap();
assert_eq!(inviter_of(&pool, team, invitee).await, Some(owner));
Expand Down
12 changes: 6 additions & 6 deletions src/routes/member_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ mod db_tests {
Extension(AuthUser(actor)),
Extension(SyncNotifier::new()),
Path(team),
Json(InviteMemberRequest { email: email.into(), role: None, name: name.map(str::to_string) }),
Json(InviteMemberRequest { email: email.into(), role: None, name: name.map(str::to_string), role_ids: None }),
)
.await
.map(|_| ())
Expand Down Expand Up @@ -403,7 +403,7 @@ mod db_tests {
Extension(AuthUser(admin)),
Extension(SyncNotifier::new()),
Path(team),
Json(AddMemberRequest { email: None, user_id: Some(invitee), role: None, name: Some("Eva".into()) }),
Json(AddMemberRequest { email: None, user_id: Some(invitee), role: None, name: Some("Eva".into()), role_ids: None }),
)
.await
.expect("invite");
Expand Down Expand Up @@ -433,15 +433,15 @@ mod db_tests {
let user = seed_user(&pool).await;
let mut conn = pool.acquire().await.unwrap();

crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", Some("Jan")).await.unwrap();
crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", &[], Some("Jan")).await.unwrap();
sqlx::query("DELETE FROM team_members WHERE team_id = $1 AND user_id = $2")
.bind(team).bind(user).execute(&pool).await.unwrap();
assert_eq!(stored(&pool, team, user).await.as_deref(), Some("Jan"), "name survives departure");

crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", None).await.unwrap();
crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", &[], None).await.unwrap();
assert_eq!(stored(&pool, team, user).await.as_deref(), Some("Jan"));

crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", Some("Jan Nováková")).await.unwrap();
crate::routes::invitations::admit_member(&mut conn, team, user, Some(owner), "member", &[], Some("Jan Nováková")).await.unwrap();
assert_eq!(stored(&pool, team, user).await.as_deref(), Some("Jan Nováková"));
}

Expand Down Expand Up @@ -594,7 +594,7 @@ mod db_tests {
Extension(AuthUser(admin)),
Extension(SyncNotifier::new()),
Path(team),
Json(AddMemberRequest { email: None, user_id: Some(by_id), role: None, name: Some("ById".into()) }),
Json(AddMemberRequest { email: None, user_id: Some(by_id), role: None, name: Some("ById".into()), role_ids: None }),
)
.await
.expect("invite");
Expand Down
2 changes: 1 addition & 1 deletion src/routes/team_grants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ pub async fn redeem_grant(

// `invited_by` is the grant's creator, so the roster attributes the joiner
// to whoever minted the link.
admit_member(&mut tx, locked.team_id, auth.0, Some(locked.created_by), &locked.role, None).await?;
admit_member(&mut tx, locked.team_id, auth.0, Some(locked.created_by), &locked.role, &[], None).await?;

// Fill a missing key only. Overwriting a key a user already published
// would orphan every vault key already wrapped to it, in this team and
Expand Down
Loading
Loading