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}}
+
+ {{/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);
+}