diff --git a/app/router.js b/app/router.js
index 5a5f85cea47..7c98f97a3a4 100644
--- a/app/router.js
+++ b/app/router.js
@@ -48,6 +48,7 @@ Router.map(function() {
this.route('policies');
this.route('data-access');
this.route('confirm', { path: '/confirm/:email_token' });
+ this.route('accept-invite', { path: '/accept-invite/:token' });
this.route('catch-all', { path: '*path' });
});
diff --git a/app/routes/accept-invite.js b/app/routes/accept-invite.js
new file mode 100644
index 00000000000..a8669bec927
--- /dev/null
+++ b/app/routes/accept-invite.js
@@ -0,0 +1,15 @@
+import Route from '@ember/routing/route';
+import ajax from 'ember-fetch/ajax';
+
+export default Route.extend({
+ async model(params) {
+ try {
+ await ajax(`/api/v1/me/crate_owner_invitations/accept/${params.token}`, { method: 'PUT', body: '{}' });
+ this.set('response', { accepted: true });
+ return { response: this.get('response') };
+ } catch (error) {
+ this.set('response', { accepted: false });
+ return { response: this.get('response') };
+ }
+ },
+});
diff --git a/app/templates/accept-invite.hbs b/app/templates/accept-invite.hbs
new file mode 100644
index 00000000000..3c2ea989860
--- /dev/null
+++ b/app/templates/accept-invite.hbs
@@ -0,0 +1,7 @@
+{{#if this.model.response.accepted}}
+
You've been added as a crate owner!
+ Visit your dashboard to view all of your crates, or account settings to manage email notification preferences for all of your crates.
+{{else}}
+ Error in accepting crate ownership.
+ You may want to visit crates.io/me/pending-invites to try again.
+{{/if}}
diff --git a/migrations/2019-12-13-053745_add_crate_owner_invitation_token/down.sql b/migrations/2019-12-13-053745_add_crate_owner_invitation_token/down.sql
new file mode 100644
index 00000000000..84d63bec0e1
--- /dev/null
+++ b/migrations/2019-12-13-053745_add_crate_owner_invitation_token/down.sql
@@ -0,0 +1,4 @@
+DROP TRIGGER trigger_crate_owner_invitations_set_token_generated_at ON crate_owner_invitations;
+DROP FUNCTION crate_owner_invitations_set_token_generated_at();
+ALTER TABLE crate_owner_invitations DROP COLUMN token_generated_at;
+ALTER TABLE crate_owner_invitations DROP COLUMN token;
diff --git a/migrations/2019-12-13-053745_add_crate_owner_invitation_token/up.sql b/migrations/2019-12-13-053745_add_crate_owner_invitation_token/up.sql
new file mode 100644
index 00000000000..9b8452d9635
--- /dev/null
+++ b/migrations/2019-12-13-053745_add_crate_owner_invitation_token/up.sql
@@ -0,0 +1,13 @@
+ALTER TABLE crate_owner_invitations ADD COLUMN token TEXT NOT NULL DEFAULT random_string(26);
+ALTER TABLE crate_owner_invitations ADD COLUMN token_generated_at TIMESTAMP;
+
+CREATE FUNCTION crate_owner_invitations_set_token_generated_at() RETURNS trigger AS $$
+ BEGIN
+ NEW.token_generated_at := CURRENT_TIMESTAMP;
+ RETURN NEW;
+ END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_crate_owner_invitations_set_token_generated_at BEFORE
+INSERT OR UPDATE OF token ON crate_owner_invitations
+FOR EACH ROW EXECUTE PROCEDURE crate_owner_invitations_set_token_generated_at();
diff --git a/src/controllers/crate_owner_invitation.rs b/src/controllers/crate_owner_invitation.rs
index 655946d2351..aa313efff5e 100644
--- a/src/controllers/crate_owner_invitation.rs
+++ b/src/controllers/crate_owner_invitation.rs
@@ -41,23 +41,44 @@ pub fn handle_invite(req: &mut dyn Request) -> AppResult {
serde_json::from_str(&body).map_err(|_| bad_request("invalid json request"))?;
let crate_invite = crate_invite.crate_owner_invite;
+ let user_id = req.user()?.id;
if crate_invite.accepted {
- accept_invite(req, conn, crate_invite)
+ accept_invite(req, conn, crate_invite, user_id)
} else {
decline_invite(req, conn, crate_invite)
}
}
+/// Handles the `PUT /me/crate_owner_invitations/accept/:token` route.
+pub fn handle_invite_with_token(req: &mut dyn Request) -> AppResult {
+ let conn = req.db_conn()?;
+ let req_token = &req.params()["token"];
+
+ let crate_owner_invite: CrateOwnerInvitation = crate_owner_invitations::table
+ .filter(crate_owner_invitations::token.eq(req_token))
+ .first::(&*conn)?;
+
+ let invite_reponse = InvitationResponse {
+ crate_id: crate_owner_invite.crate_id,
+ accepted: true,
+ };
+ accept_invite(
+ req,
+ &conn,
+ invite_reponse,
+ crate_owner_invite.invited_user_id,
+ )
+}
+
fn accept_invite(
req: &dyn Request,
conn: &PgConnection,
crate_invite: InvitationResponse,
+ user_id: i32,
) -> AppResult {
use diesel::{delete, insert_into};
- let user_id = req.user()?.id;
-
conn.transaction(|| {
let pending_crate_owner = crate_owner_invitations::table
.find((user_id, crate_invite.crate_id))
diff --git a/src/email.rs b/src/email.rs
index d4f6af7f605..1dd1211da6d 100644
--- a/src/email.rs
+++ b/src/email.rs
@@ -90,13 +90,13 @@ https://crates.io/confirm/{}",
/// Whether or not the email is sent, the invitation entry will be created in
/// the database and the user will see the invitation when they visit
/// https://crates.io/me/pending-invites/.
-pub fn send_owner_invite_email(email: &str, user_name: &str, crate_name: &str) {
+pub fn send_owner_invite_email(email: &str, user_name: &str, crate_name: &str, token: &str) {
let subject = "Crate ownership invitation";
let body = format!(
"{} has invited you to become an owner of the crate {}!\n
-Please visit https://crates.io/me/pending-invites to accept or reject
-this invitation.",
- user_name, crate_name
+Visit https://crates.io/accept-invite/{} to accept this invitation,
+or go to https://crates.io/me/pending-invites to manage all of your crate ownership invitations.",
+ user_name, crate_name, token
);
let _ = send_email(email, subject, &body);
diff --git a/src/models/crate_owner_invitation.rs b/src/models/crate_owner_invitation.rs
index 2ede8fbe18d..e94fac6cac6 100644
--- a/src/models/crate_owner_invitation.rs
+++ b/src/models/crate_owner_invitation.rs
@@ -5,13 +5,15 @@ use crate::schema::{crate_owner_invitations, crates, users};
use crate::views::EncodableCrateOwnerInvitation;
/// The model representing a row in the `crate_owner_invitations` database table.
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Identifiable, Queryable)]
+#[derive(Clone, Debug, PartialEq, Eq, Identifiable, Queryable)]
#[primary_key(invited_user_id, crate_id)]
pub struct CrateOwnerInvitation {
pub invited_user_id: i32,
pub invited_by_user_id: i32,
pub crate_id: i32,
pub created_at: NaiveDateTime,
+ pub token: String,
+ pub token_created_at: Option,
}
#[derive(Insertable, Clone, Copy, Debug)]
diff --git a/src/models/krate.rs b/src/models/krate.rs
index 46d035a4dae..5494ded40c3 100644
--- a/src/models/krate.rs
+++ b/src/models/krate.rs
@@ -440,12 +440,13 @@ impl Crate {
.get_result::(conn)
.optional()?;
- if maybe_inserted.is_some() {
+ if let Some(ownership_invitation) = maybe_inserted {
if let Ok(Some(email)) = user.verified_email(&conn) {
email::send_owner_invite_email(
&email.as_str(),
&req_user.gh_login.as_str(),
&self.name.as_str(),
+ &ownership_invitation.token.as_str(),
);
}
}
diff --git a/src/router.rs b/src/router.rs
index a4b01ef9e02..f4ec258dd90 100644
--- a/src/router.rs
+++ b/src/router.rs
@@ -89,6 +89,10 @@ pub fn build_router(app: &App) -> R404 {
"/me/crate_owner_invitations/:crate_id",
C(crate_owner_invitation::handle_invite),
);
+ api_router.put(
+ "/me/crate_owner_invitations/accept/:token",
+ C(crate_owner_invitation::handle_invite_with_token),
+ );
api_router.put(
"/me/email_notifications",
C(user::me::update_email_notifications),
diff --git a/src/schema.rs b/src/schema.rs
index c2be65ec8e3..b765de57fe5 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -208,6 +208,18 @@ table! {
///
/// (Automatically generated by Diesel.)
created_at -> Timestamp,
+ /// The `token` column of the `crate_owner_invitations` table.
+ ///
+ /// Its SQL type is `Text`.
+ ///
+ /// (Automatically generated by Diesel.)
+ token -> Text,
+ /// The `token_generated_at` column of the `crate_owner_invitations` table.
+ ///
+ /// Its SQL type is `Nullable`.
+ ///
+ /// (Automatically generated by Diesel.)
+ token_generated_at -> Nullable,
}
}
diff --git a/src/tasks/dump_db/dump-db.toml b/src/tasks/dump_db/dump-db.toml
index bf552c6ad57..7d514e631aa 100644
--- a/src/tasks/dump_db/dump-db.toml
+++ b/src/tasks/dump_db/dump-db.toml
@@ -56,6 +56,8 @@ invited_user_id = "private"
invited_by_user_id = "private"
crate_id = "private"
created_at = "private"
+token = "private"
+token_generated_at = "private"
[crate_owners]
dependencies = ["crates", "users"]