diff --git a/app/components/pending-owner-invite-row.js b/app/components/pending-owner-invite-row.js new file mode 100644 index 00000000000..a705c4974c9 --- /dev/null +++ b/app/components/pending-owner-invite-row.js @@ -0,0 +1,45 @@ +import Ember from 'ember'; + +export default Ember.Component.extend({ + isAccepted: false, + isDeclined: false, + isError: false, + inviteError: 'default error message', + + actions: { + acceptInvitation(invite) { + invite.set('accepted', true); + invite.save() + .then(() => { + this.set('isAccepted', true); + }) + .catch((error) => { + this.set('isError', true); + if (error.payload) { + this.set('inviteError', + `Error in accepting invite: ${error.payload.errors[0].detail}` + ); + } else { + this.set('inviteError', 'Error in accepting invite'); + } + }); + }, + declineInvitation(invite) { + invite.set('accepted', false); + invite.save() + .then(() => { + this.set('isDeclined', true); + }) + .catch((error) => { + this.set('isError', true); + if (error.payload) { + this.set('inviteError', + `Error in declining invite: ${error.payload.errors[0].detail}` + ); + } else { + this.set('inviteError', 'Error in declining invite'); + } + }); + } + } +}); diff --git a/app/models/crate-owner-invite.js b/app/models/crate-owner-invite.js index c3103b49708..c6daf6e6008 100644 --- a/app/models/crate-owner-invite.js +++ b/app/models/crate-owner-invite.js @@ -4,5 +4,6 @@ export default DS.Model.extend({ invited_by_username: DS.attr('string'), crate_name: DS.attr('string'), crate_id: DS.attr('number'), - created_at: DS.attr('date') + created_at: DS.attr('date'), + accepted: DS.attr('boolean', { defaultValue: false }) }); diff --git a/app/serializers/crate-owner-invite.js b/app/serializers/crate-owner-invite.js index 652db050a4c..c81d24c6624 100644 --- a/app/serializers/crate-owner-invite.js +++ b/app/serializers/crate-owner-invite.js @@ -4,5 +4,8 @@ export default DS.RESTSerializer.extend({ primaryKey: 'crate_id', modelNameFromPayloadKey() { return 'crate-owner-invite'; + }, + payloadKeyFromModelName() { + return 'crate_owner_invite'; } }); diff --git a/app/styles/me.scss b/app/styles/me.scss index ac07a8a30f6..07a8068f4ad 100644 --- a/app/styles/me.scss +++ b/app/styles/me.scss @@ -195,6 +195,14 @@ @include justify-content(space-between); .date { @include flex-grow(2); text-align: right; } + .label { + .small-text { + font-size: 90%; + } + } + .name { + width: 200px; + } } } diff --git a/app/templates/components/pending-owner-invite-row.hbs b/app/templates/components/pending-owner-invite-row.hbs new file mode 100644 index 00000000000..ba4f86fc883 --- /dev/null +++ b/app/templates/components/pending-owner-invite-row.hbs @@ -0,0 +1,40 @@ +
+ {{#if isAccepted }} +

Success! You've been added as an owner of crate + {{#link-to 'crate' invite.crate_name}}{{invite.crate_name}}{{/link-to}}. +

+ {{else if isDeclined}} +

Declined. You have not been added as an owner of crate + {{#link-to 'crate' invite.crate_name}}{{invite.crate_name}}{{/link-to}}. +

+ {{else}} +
+
+

+ {{#link-to 'crate' invite.crate_name}} + {{invite.crate_name}} + {{/link-to}} +

+
+
+

Invited by: + {{#link-to 'user' invite.invited_by_username}} + {{invite.invited_by_username}} + {{/link-to}} +

+
+
+ {{moment-from-now invite.created_at}} +
+
+ + +
+ {{#if isError}} +
+

{{inviteError}}

+
+ {{/if}} +
+ {{/if}} +
diff --git a/app/templates/me/pending-invites.hbs b/app/templates/me/pending-invites.hbs index df6f0e8e985..f20ef3d15a2 100644 --- a/app/templates/me/pending-invites.hbs +++ b/app/templates/me/pending-invites.hbs @@ -8,31 +8,9 @@
{{#each model as |invite|}} -
-
-
-

- {{#link-to 'crate' invite.crate_name}} - {{invite.crate_name}} - {{/link-to}} -

-
-
-

Invited by: - {{#link-to 'user' invite.invited_by_username}} - {{invite.invited_by_username}} - {{/link-to}} -

-
-
- {{moment-from-now invite.created_at}} -
-
- - -
-
-
+ {{pending-owner-invite-row invite=invite}} + {{else}} +

You don't seem to have any pending invitations.

{{/each}}
diff --git a/src/crate_owner_invitation.rs b/src/crate_owner_invitation.rs index 085fa84f3ec..bd5b0f6681f 100644 --- a/src/crate_owner_invitation.rs +++ b/src/crate_owner_invitation.rs @@ -1,12 +1,14 @@ use conduit::{Request, Response}; use diesel::prelude::*; use time::Timespec; +use serde_json; use db::RequestTransaction; -use schema::{crate_owner_invitations, users, crates}; +use schema::{crate_owner_invitations, users, crates, crate_owners}; use user::RequestUser; -use util::errors::CargoResult; +use util::errors::{CargoResult, human}; use util::RequestUtils; +use owner::{CrateOwner, OwnerKind}; /// The model representing a row in the `crate_owner_invitations` database table. #[derive(Clone, Copy, Debug, PartialEq, Eq, Identifiable, Queryable)] @@ -80,3 +82,93 @@ pub fn list(req: &mut Request) -> CargoResult { } Ok(req.json(&R { crate_owner_invitations })) } + +#[derive(Deserialize)] +struct OwnerInvitation { + crate_owner_invite: InvitationResponse, +} + +#[derive(Deserialize, Serialize, Debug, Copy, Clone)] +pub struct InvitationResponse { + pub crate_id: i32, + pub accepted: bool, +} + +/// Handles the `PUT /me/crate_owner_invitations/:crate_id` route. +pub fn handle_invite(req: &mut Request) -> CargoResult { + + let conn = &*req.db_conn()?; + + + let mut body = String::new(); + req.body().read_to_string(&mut body)?; + + let crate_invite: OwnerInvitation = serde_json::from_str(&body).map_err(|_| { + human("invalid json request") + })?; + + let crate_invite = crate_invite.crate_owner_invite; + + if crate_invite.accepted { + accept_invite(req, conn, crate_invite) + } else { + decline_invite(req, conn, crate_invite) + } +} + +fn accept_invite( + req: &mut Request, + conn: &PgConnection, + crate_invite: InvitationResponse, +) -> CargoResult { + let user_id = req.user()?.id; + use diesel::{insert, delete}; + let pending_crate_owner = crate_owner_invitations::table + .filter(crate_owner_invitations::crate_id.eq(crate_invite.crate_id)) + .filter(crate_owner_invitations::invited_user_id.eq(user_id)) + .first::(&*conn)?; + + let owner = CrateOwner { + crate_id: crate_invite.crate_id, + owner_id: user_id, + created_by: pending_crate_owner.invited_by_user_id, + owner_kind: OwnerKind::User as i32, + }; + + conn.transaction(|| { + insert(&owner).into(crate_owners::table).execute(conn)?; + delete( + crate_owner_invitations::table + .filter(crate_owner_invitations::crate_id.eq(crate_invite.crate_id)) + .filter(crate_owner_invitations::invited_user_id.eq(user_id)), + ).execute(conn)?; + + #[derive(Serialize)] + struct R { + crate_owner_invitation: InvitationResponse, + } + Ok(req.json(&R { crate_owner_invitation: crate_invite })) + }) +} + +fn decline_invite( + req: &mut Request, + conn: &PgConnection, + crate_invite: InvitationResponse, +) -> CargoResult { + use diesel::delete; + let user_id = req.user()?.id; + + delete( + crate_owner_invitations::table + .filter(crate_owner_invitations::crate_id.eq(crate_invite.crate_id)) + .filter(crate_owner_invitations::invited_user_id.eq(user_id)), + ).execute(conn)?; + + #[derive(Serialize)] + struct R { + crate_owner_invitation: InvitationResponse, + } + + Ok(req.json(&R { crate_owner_invitation: crate_invite })) +} diff --git a/src/lib.rs b/src/lib.rs index 442ed59d7ec..705f9dbc8b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -191,6 +191,10 @@ pub fn middleware(app: Arc) -> MiddlewareBuilder { "/me/crate_owner_invitations", C(crate_owner_invitation::list), ); + api_router.put( + "/me/crate_owner_invitations/:crate_id", + C(crate_owner_invitation::handle_invite), + ); api_router.get("/summary", C(krate::summary)); api_router.put("/confirm/:email_token", C(user::confirm_user_email)); api_router.put("/users/:user_id/resend", C(user::regenerate_token_and_send)); diff --git a/src/tests/all.rs b/src/tests/all.rs index 633ec9533b0..caf07d7ef16 100644 --- a/src/tests/all.rs +++ b/src/tests/all.rs @@ -15,6 +15,7 @@ extern crate dotenv; extern crate git2; extern crate semver; extern crate serde; +#[macro_use] extern crate serde_json; extern crate time; extern crate url; diff --git a/src/tests/owners.rs b/src/tests/owners.rs index b19c59fae50..119a9af1007 100644 --- a/src/tests/owners.rs +++ b/src/tests/owners.rs @@ -3,7 +3,7 @@ use {CrateList, GoodCrate}; use cargo_registry::owner::EncodableOwner; use cargo_registry::user::EncodablePublicUser; use cargo_registry::crate_owner_invitation::{EncodableCrateOwnerInvitation, - NewCrateOwnerInvitation}; + NewCrateOwnerInvitation, InvitationResponse}; use cargo_registry::schema::crate_owner_invitations; use conduit::{Handler, Method}; @@ -370,3 +370,198 @@ fn invitations_list() { assert_eq!(json.crate_owner_invitations[0].crate_name, "invited_crate"); assert_eq!(json.crate_owner_invitations[0].crate_id, krate.id); } + +/* Given a user inviting a different user to be a crate + owner, check that the user invited can accept their + invitation, the invitation will be deleted from + the invitations table, and a new crate owner will be + inserted into the table for the given crate. +*/ +#[test] +fn test_accept_invitation() { + #[derive(Deserialize)] + struct R { + crate_owner_invitations: Vec, + } + + #[derive(Deserialize)] + struct Q { + users: Vec, + } + + #[derive(Deserialize)] + struct T { + crate_owner_invitation: InvitationResponse, + } + + let (_b, app, middle) = ::app(); + let mut req = ::req( + app.clone(), + Method::Get, + "/api/v1/me/crate_owner_invitations", + ); + let (krate, user) = { + let conn = app.diesel_database.get().unwrap(); + let owner = ::new_user("inviting_user").create_or_update(&conn).unwrap(); + let user = ::new_user("invited_user").create_or_update(&conn).unwrap(); + let krate = ::CrateBuilder::new("invited_crate", owner.id).expect_build(&conn); + + // This should be replaced by an actual call to the route that `owner --add` hits once + // that route creates an invitation. + let invitation = NewCrateOwnerInvitation { + invited_by_user_id: owner.id, + invited_user_id: user.id, + crate_id: krate.id, + }; + diesel::insert(&invitation) + .into(crate_owner_invitations::table) + .execute(&*conn) + .unwrap(); + (krate, user) + }; + ::sign_in_as(&mut req, &user); + + let body = json!({ + "crate_owner_invite": { + "invited_by_username": "inviting_user", + "crate_name": "invited_crate", + "crate_id": krate.id, + "created_at": "", + "accepted": true + } + }); + + // first check that response from inserting new crate owner + // and deleting crate_owner_invitation is okay + let mut response = ok_resp!( + middle.call( + req.with_path(&format!("api/v1/me/crate_owner_invitations/{}", krate.id)) + .with_method(Method::Put) + .with_body(body.to_string().as_bytes()), + ) + ); + + let json: T = ::json(&mut response); + assert_eq!(json.crate_owner_invitation.accepted, true); + assert_eq!(json.crate_owner_invitation.crate_id, krate.id); + + // then check to make sure that accept_invite did what it + // was supposed to + // crate_owner_invitation was deleted + let mut response = ok_resp!( + middle.call( + req.with_path("api/v1/me/crate_owner_invitations") + .with_method(Method::Get) + ) + ); + let json: R = ::json(&mut response); + assert_eq!(json.crate_owner_invitations.len(), 0); + + // new crate owner was inserted + let mut response = ok_resp!( + middle.call( + req.with_path("/api/v1/crates/invited_crate/owners") + .with_method(Method::Get) + ) + ); + let json: Q = ::json(&mut response); + assert_eq!(json.users.len(), 2); +} + + +/* Given a user inviting a different user to be a crate + owner, check that the user invited can decline their + invitation and the invitation will be deleted from + the invitations table. +*/ +#[test] +fn test_decline_invitation() { + #[derive(Deserialize)] + struct R { + crate_owner_invitations: Vec, + } + + #[derive(Deserialize)] + struct Q { + users: Vec, + } + + #[derive(Deserialize)] + struct T { + crate_owner_invitation: InvitationResponse, + } + + let (_b, app, middle) = ::app(); + let mut req = ::req( + app.clone(), + Method::Get, + "/api/v1/me/crate_owner_invitations", + ); + let (krate, user) = { + let conn = app.diesel_database.get().unwrap(); + let owner = ::new_user("inviting_user").create_or_update(&conn).unwrap(); + let user = ::new_user("invited_user").create_or_update(&conn).unwrap(); + let krate = ::CrateBuilder::new("invited_crate", owner.id).expect_build(&conn); + + // This should be replaced by an actual call to the route that `owner --add` hits once + // that route creates an invitation. + let invitation = NewCrateOwnerInvitation { + invited_by_user_id: owner.id, + invited_user_id: user.id, + crate_id: krate.id, + }; + diesel::insert(&invitation) + .into(crate_owner_invitations::table) + .execute(&*conn) + .unwrap(); + (krate, user) + }; + ::sign_in_as(&mut req, &user); + + let body = json!({ + "crate_owner_invite": { + "invited_by_username": "inviting_user", + "crate_name": "invited_crate", + "crate_id": krate.id, + "created_at": "", + "accepted": false + } + }); + + // first check that response from deleting + // crate_owner_invitation is okay + let mut response = ok_resp!( + middle.call( + req.with_path(&format!("api/v1/me/crate_owner_invitations/{}", krate.id)) + .with_method(Method::Put) + .with_body(body.to_string().as_bytes()), + ) + ); + + let json: T = ::json(&mut response); + assert_eq!(json.crate_owner_invitation.accepted, false); + assert_eq!(json.crate_owner_invitation.crate_id, krate.id); + + + // then check to make sure that decline_invite did what it + // was supposed to + // crate_owner_invitation was deleted + let mut response = ok_resp!( + middle.call( + req.with_path("api/v1/me/crate_owner_invitations") + .with_method(Method::Get) + ) + ); + let json: R = ::json(&mut response); + assert_eq!(json.crate_owner_invitations.len(), 0); + + // new crate owner was not inserted + let mut response = ok_resp!( + middle.call( + req.with_path("/api/v1/crates/invited_crate/owners") + .with_method(Method::Get) + ) + ); + let json: Q = ::json(&mut response); + assert_eq!(json.users.len(), 1); +}