diff --git a/Gemfile b/Gemfile index 687389f86..0e74b4f45 100644 --- a/Gemfile +++ b/Gemfile @@ -4,9 +4,9 @@ gem 'rails', '3.0.0' gem 'bundler', '>= 1.0.0' #Security -gem 'devise', '1.1.2' +gem 'devise', '1.1.3' gem 'devise-mongo_mapper', :git => 'git://github.com/collectiveidea/devise-mongo_mapper' - +gem 'devise_invitable', '~> 0.3.4' #Mongo gem 'mongo_mapper', :branch => 'rails3', :git => 'http://github.com/jnunemaker/mongomapper.git' gem 'bson_ext', '1.1' diff --git a/Gemfile.lock b/Gemfile.lock index 8876632a6..a2a905cc5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -49,21 +49,22 @@ GIT GIT remote: http://github.com/dcu/magent.git - revision: 06513f3dac812469a55f2e365c349af4d2abc92a + revision: 5d664351b305141158fc69fc495456414821adb3 specs: - magent (0.4.2) - mongo (>= 0.1.0) - uuidtools (>= 2.0.0) + magent (1.0.0) + em-websocket + mongo + uuidtools GIT remote: http://github.com/jnunemaker/mongomapper.git - revision: 5a3328244b641fa4f6a3743e9e0b4dd6a1ac2700 + revision: fd59b0ab068be7321f8e84b9dc12fb4fa6b8535d branch: rails3 specs: mongo_mapper (0.8.4) activemodel (~> 3.0.0) activesupport (~> 3.0.0) - plucky (~> 0.3.5) + plucky (~> 0.3.6) GEM remote: http://rubygems.org/ @@ -119,6 +120,8 @@ GEM rack (>= 1.0.0) rack-test (>= 0.5.4) selenium-webdriver (>= 0.0.3) + childprocess (0.0.7) + ffi (~> 0.6.3) columnize (0.3.1) crack (0.1.8) cucumber (0.9.2) @@ -132,9 +135,11 @@ GEM culerity (0.2.12) daemons (1.1.0) database_cleaner (0.5.2) - devise (1.1.2) + devise (1.1.3) bcrypt-ruby (~> 2.1.2) warden (~> 0.10.7) + devise_invitable (0.3.4) + devise (~> 1.1.0) diff-lcs (1.1.2) em-websocket (0.1.4) addressable (>= 2.1.1) @@ -171,7 +176,7 @@ GEM subexec (~> 0.0.4) mocha (0.9.8) rake - mongo (1.0.9) + mongo (1.1) bson (>= 1.0.5) net-scp (1.0.4) net-ssh (>= 1.99.1) @@ -181,8 +186,8 @@ GEM net-ssh-gateway (1.0.1) net-ssh (>= 1.99.1) nokogiri (1.4.3.1) - plucky (0.3.5) - mongo (~> 1.0.8) + plucky (0.3.6) + mongo (~> 1.1) polyglot (0.3.1) pubsubhubbub (0.1.1) em-http-request (>= 0.1.5) @@ -208,16 +213,16 @@ GEM rake (0.8.7) rest-client (1.6.1) mime-types (>= 1.16) - rspec (2.0.0.rc) - rspec-core (= 2.0.0.rc) - rspec-expectations (= 2.0.0.rc) - rspec-mocks (= 2.0.0.rc) - rspec-core (2.0.0.rc) - rspec-expectations (2.0.0.rc) + rspec (2.0.0) + rspec-core (= 2.0.0) + rspec-expectations (= 2.0.0) + rspec-mocks (= 2.0.0) + rspec-core (2.0.0) + rspec-expectations (2.0.0) diff-lcs (>= 1.1.2) - rspec-mocks (2.0.0.rc) - rspec-core (= 2.0.0.rc) - rspec-expectations (= 2.0.0.rc) + rspec-mocks (2.0.0) + rspec-core (= 2.0.0) + rspec-expectations (= 2.0.0) rspec-rails (2.0.0.beta.17) rspec (>= 2.0.0.beta.14) webrat (>= 0.7.0) @@ -227,8 +232,9 @@ GEM ruby-debug-base (0.10.3) linecache (>= 0.3) rubyzip (0.9.4) - selenium-webdriver (0.0.28) - ffi (>= 0.6.1) + selenium-webdriver (0.0.29) + childprocess (>= 0.0.7) + ffi (~> 0.6.3) json_pure rubyzip subexec (0.0.4) @@ -267,8 +273,9 @@ DEPENDENCIES carrierwave! cucumber-rails (= 0.3.2) database_cleaner - devise (= 1.1.2) + devise (= 1.1.3) devise-mongo_mapper! + devise_invitable (~> 0.3.4) em-http-request! em-websocket factory_girl_rails diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb new file mode 100644 index 000000000..852904458 --- /dev/null +++ b/app/controllers/invitations_controller.rb @@ -0,0 +1,21 @@ +# Copyright (c) 2010, Diaspora Inc. This file is +# licensed under the Affero General Public License version 3 or later. See +# the COPYRIGHT file. + +class InvitationsController < Devise::InvitationsController + def update + begin + user = User.find_by_invitation_token(params["user"]["invitation_token"]) + user.accept_invitation!(params["user"]) + rescue MongoMapper::DocumentNotValid => e + user = nil + flash[:error] = e.message + end + if user + flash[:notice] = I18n.t 'registrations.create.success' + sign_in_and_redirect(:user, user) + else + redirect_to new_user_registration_path + end + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9ef461b20..030cfe56d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -47,6 +47,13 @@ class UsersController < ApplicationController end + def destroy + current_user.destroy + sign_out current_user + flash[:notice] = t('user.destroy') + redirect_to root_path + end + def public user = User.find_by_username(params[:username]) @@ -71,6 +78,10 @@ class UsersController < ApplicationController send_data( File.open(tar_path).read, :filename => "#{current_user.id}.tar" ) end + def invite + User.invite!(:email => params[:email]) + end + private def prep_image_url(params) url = APP_CONFIG[:pod_url].chop if APP_CONFIG[:pod_url][-1,1] == '/' diff --git a/app/models/person.rb b/app/models/person.rb index c2485d40d..2798ac3fb 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -159,6 +159,5 @@ class Person private def remove_all_traces Post.all(:person_id => id).each{|p| p.delete} - Album.all(:person_id => id).each{|p| p.delete} end end diff --git a/app/models/user.rb b/app/models/user.rb index d1ead85cf..12322dca6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -7,6 +7,16 @@ require File.join(Rails.root, 'lib/diaspora/user/querying') require File.join(Rails.root, 'lib/diaspora/user/receiving') require File.join(Rails.root, 'lib/salmon/salmon') +class InvitedUserValidator < ActiveModel::Validator + def validate(document) + unless document.invitation_token + unless document.person + document.errors[:base] << "Unless you are being invited, you must have a person" + end + end + end +end + class User include MongoMapper::Document plugin MongoMapper::Devise @@ -16,11 +26,13 @@ class User include Encryptor::Private QUEUE = MessageHandler.new - devise :database_authenticatable, :registerable, + devise :invitable, :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable key :username, :unique => true key :serialized_private_key, String + key :invitation_token, String + key :invitation_sent_at, DateTime key :friend_ids, Array key :pending_request_ids, Array key :visible_post_ids, Array @@ -38,6 +50,9 @@ class User after_create :seed_aspects before_validation :downcase_username, :on => :create + validates_with InvitedUserValidator + + before_destroy :unfriend_everyone, :remove_person def self.find_for_authentication(conditions={}) if conditions[:username] =~ /^([\w\.%\+\-]+)@([\w\-]+\.)+([\w]{2,})$/i # email regex @@ -252,6 +267,27 @@ class User end ###Helpers############ + + def accept_invitation!( opts = {} ) + if self.invited? + self.username = opts[:username] + self.password = opts[:password] + self.password_confirmation = opts[:password_confirmation] + opts[:person][:diaspora_handle] = "#{opts[:username]}@#{APP_CONFIG[:terse_pod_url]}" + opts[:person][:url] = APP_CONFIG[:pod_url] + + opts[:serialized_private_key] = User.generate_key + self.serialized_private_key = opts[:serialized_private_key] + opts[:person][:serialized_public_key] = opts[:serialized_private_key].public_key + + person_hash = opts.delete(:person) + self.person = Person.create(person_hash) + self.person.save + self.save + self + end + end + def self.instantiate!( opts = {} ) opts[:person][:diaspora_handle] = "#{opts[:username]}@#{APP_CONFIG[:terse_pod_url]}" opts[:person][:url] = APP_CONFIG[:pod_url] @@ -293,5 +329,20 @@ class User def encryption_key OpenSSL::PKey::RSA.new( serialized_private_key ) end + + protected + def remove_person + self.person.destroy + end + + def unfriend_everyone + friends.each{ |friend| + if friend.owner? + friend.owner.unfriended_by self.person + else + self.unfriend friend + end + } + end end diff --git a/app/views/aspects/manage.html.haml b/app/views/aspects/manage.html.haml index 5573fe020..85296669f 100644 --- a/app/views/aspects/manage.html.haml +++ b/app/views/aspects/manage.html.haml @@ -21,13 +21,20 @@ = request.person.real_name %h3=t('.ignore_remove') - %li.remove + .remove %ul.dropzone %li.grey Drag to ignore/remove + %h3= link_to "Invite a friend!", "#invite_user_pane", :id => "invite_user_button", :class => "invite_user_button", :title => "Invite a friend" + + .yo{ :style => "display:none;"} + #invite_user_pane + = render "invitations/new" + - content_for :publish do = link_to(t('.add_a_new_aspect'), "#add_aspect_pane", :id => "add_aspect_button", :class => "new_aspect button", :title => t('.add_a_new_aspect')) + %ul#aspect_list - for aspect in @aspects %li.aspect diff --git a/app/views/invitations/_new.haml b/app/views/invitations/_new.haml new file mode 100644 index 000000000..208e77640 --- /dev/null +++ b/app/views/invitations/_new.haml @@ -0,0 +1,8 @@ +%h2 Send invitation += form_for User.new, :url => invitation_path(User) do |f| + /= devise_error_messages! + %p + = f.label :email + = f.text_field :email + %p= f.submit "Send an invitation" +/= link_to "Home", after_sign_in_path_for(resource_name) diff --git a/app/views/invitations/edit.html.haml b/app/views/invitations/edit.html.haml new file mode 100644 index 000000000..e21b0c276 --- /dev/null +++ b/app/views/invitations/edit.html.haml @@ -0,0 +1,25 @@ += image_tag "http://needcoffee.cachefly.net/needcoffee/uploads/2009/02/predator-arnold-schwarzenegger.jpg" + += form_for(resource, :as => resource_name, :url => invitation_path(resource_name), :html => {:method => :put }) do |f| + %p + = f.label :username + = f.text_field :username + %p + = f.label :password + = f.password_field :password + %p + = f.label :password_confirmation + = f.password_field :password_confirmation + + = f.fields_for :person do |p| + = p.fields_for :profile do |pr| + %p + = pr.label :first_name + = pr.text_field :first_name + %p + = pr.label :last_name + = pr.text_field :last_name + + = f.hidden_field :invitation_token + = f.submit 'sign_up' += render :partial => "devise/shared/links" diff --git a/app/views/invitations/new.html.haml b/app/views/invitations/new.html.haml new file mode 100644 index 000000000..598b16a3d --- /dev/null +++ b/app/views/invitations/new.html.haml @@ -0,0 +1,8 @@ +%h2 Send invitation += form_for User.new, :url => invitation_path(User) do |f| + = devise_error_messages! + %p + = f.label :email + = f.text_field :email + %p= f.submit "Send an invitation" +/= link_to "Home", after_sign_in_path_for(resource_name) diff --git a/app/views/registrations/new.html.haml b/app/views/registrations/new.html.haml index 25902720e..7e74a2157 100644 --- a/app/views/registrations/new.html.haml +++ b/app/views/registrations/new.html.haml @@ -22,6 +22,5 @@ %p = pr.label :last_name = pr.text_field :last_name - = f.submit t('.sign_up') = render :partial => "devise/shared/links" diff --git a/app/views/shared/_aspect_friends.haml b/app/views/shared/_aspect_friends.haml index 03da4ee9b..343272dff 100644 --- a/app/views/shared/_aspect_friends.haml +++ b/app/views/shared/_aspect_friends.haml @@ -19,5 +19,11 @@ = render "requests/new_request", :aspect => @aspect -else .clear + %br = link_to t('.add_friends'), aspects_manage_path +%br += link_to "Invite a friend!", "#invite_user_pane", :id => "invite_user_button", :class => "invite_user_button", :title => "Invite a friend" +.yo{ :style => "display:none;"} + #invite_user_pane + = render "invitations/new" diff --git a/app/views/users/_account.haml b/app/views/users/_account.haml index 8bd19d72d..e6b1a7ff1 100644 --- a/app/views/users/_account.haml +++ b/app/views/users/_account.haml @@ -27,3 +27,8 @@ = link_to "download my xml", users_export_path, :class => "button" = link_to "download my photos", users_export_photos_path, :class => "button" +%h3 Close Account + += link_to "Close Account", current_user, + :confirm => "Are you sure?", :method => :delete, + :class => "button" diff --git a/app/views/users/_profile.haml b/app/views/users/_profile.haml index 9b415a720..a9ea4ad4d 100644 --- a/app/views/users/_profile.haml +++ b/app/views/users/_profile.haml @@ -4,6 +4,7 @@ %h2 Profile += link_to new_user_invitation_path(current_user) = form_for @user do |f| = f.error_messages diff --git a/app/views/users/mailer/invitation.html.haml b/app/views/users/mailer/invitation.html.haml new file mode 100644 index 000000000..884a8fc27 --- /dev/null +++ b/app/views/users/mailer/invitation.html.haml @@ -0,0 +1,9 @@ +%p + Hello #{@resource.email}! +%p + Someone has invited you to #{root_url}, you can accept it through the link below. +%p= link_to 'Accept invitation', accept_invitation_url(@resource, :invitation_token => @resource.invitation_token) +%p + If you don't want to accept the invitation, please ignore this email. + %br/ + Your account won't be created until you access the link above and set your password. diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index b012b3f74..6fb026335 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -44,6 +44,11 @@ Devise.setup do |config| # Setup a pepper to generate the encrypted password. config.pepper = "065eb8798b181ff0ea2c5c16aee0ff8b70e04e2ee6bd6e08b49da46924223e39127d5335e466207d42bf2a045c12be5f90e92012a4f05f7fc6d9f3c875f4c95b" + # ==> Configuration for :invitable + # Time interval where the invitation token is valid (default: 0). + # If invite_for is 0 or nil, the invitation will never expire. + # config.invite_for = 2.weeks + # ==> Configuration for :confirmable # The time you want to give your user to confirm his account. During this time # he will be able to access your application without confirming. Default is nil. diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml new file mode 100644 index 000000000..07a2611d0 --- /dev/null +++ b/config/locales/devise.en.yml @@ -0,0 +1,46 @@ +en: + errors: + messages: + not_found: "not found" + already_confirmed: "was already confirmed" + not_locked: "was not locked" + + devise: + failure: + unauthenticated: 'You need to sign in or sign up before continuing.' + unconfirmed: 'You have to confirm your account before continuing.' + locked: 'Your account is locked.' + invalid: 'Invalid email or password.' + invalid_token: 'Invalid authentication token.' + timeout: 'Your session expired, please sign in again to continue.' + inactive: 'Your account was not activated yet.' + sessions: + signed_in: 'Signed in successfully.' + signed_out: 'Signed out successfully.' + passwords: + send_instructions: 'You will receive an email with instructions about how to reset your password in a few minutes.' + updated: 'Your password was changed successfully. You are now signed in.' + confirmations: + send_instructions: 'You will receive an email with instructions about how to confirm your account in a few minutes.' + confirmed: 'Your account was successfully confirmed. You are now signed in.' + registrations: + signed_up: 'You have signed up successfully. If enabled, a confirmation was sent to your e-mail.' + updated: 'You updated your account successfully.' + destroyed: 'Bye! Your account was successfully cancelled. We hope to see you again soon.' + unlocks: + send_instructions: 'You will receive an email with instructions about how to unlock your account in a few minutes.' + unlocked: 'Your account was successfully unlocked. You are now signed in.' + invitations: + send_instructions: 'Your invitation has been sent.' + invitation_token_invalid: 'The invitation token provided is not valid!' + updated: 'Your password was set successfully. You are now signed in.' + mailer: + confirmation_instructions: + subject: 'Confirmation instructions' + reset_password_instructions: + subject: 'Reset password instructions' + unlock_instructions: + subject: 'Unlock Instructions' + invitation: + subject: 'A friend wants you to join Diaspora!' + diff --git a/config/locales/diaspora/en.yml b/config/locales/diaspora/en.yml index 5a5cc7a43..554f8bcb8 100644 --- a/config/locales/diaspora/en.yml +++ b/config/locales/diaspora/en.yml @@ -114,6 +114,7 @@ en: you_dont_have_any_photos: "You don't have any photos! Go to the" page_to_upload_some: "page to upload some." or: "or" + destroy: "Account successfully closed." comments: comment: ago: "ago" diff --git a/config/routes.rb b/config/routes.rb index 5959f21bb..8fbd132a8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,10 +11,11 @@ Diaspora::Application.routes.draw do resources :albums devise_for :users, :controllers => {:registrations => "registrations", - :password => "devise/passwords"} + :password => "devise/passwords", + :invitations => "invitations"} # added public route to user - match 'public/:username', :to => 'users#public' - match 'users/export', :to => 'users#export' + match 'public/:username', :to => 'users#public' + match 'users/export', :to => 'users#export' match 'users/export_photos', :to => 'users#export_photos' resources :users, :except => [:create, :new, :show] diff --git a/public/javascripts/view.js b/public/javascripts/view.js index 4155a593d..eec063bcd 100644 --- a/public/javascripts/view.js +++ b/public/javascripts/view.js @@ -28,6 +28,7 @@ $(document).ready(function(){ //buttons////// $("#add_aspect_button").fancybox({ 'titleShow' : false }); $("#add_request_button").fancybox({ 'titleShow': false }); + $("#invite_user_button").fancybox({ 'titleShow': false }); $(".add_request_button").fancybox({ 'titleShow': false }); $("input[type='submit']").addClass("button"); diff --git a/spec/models/user/invite_spec.rb b/spec/models/user/invite_spec.rb new file mode 100644 index 000000000..1bb2bf0f6 --- /dev/null +++ b/spec/models/user/invite_spec.rb @@ -0,0 +1,35 @@ +# Copyright (c) 2010, Diaspora Inc. This file is +# licensed under the Affero General Public License version 3 or later. See +# the COPYRIGHT file. + +require 'spec_helper' + +describe User do + let!(:invited_user) { create_user_with_invitation("abc")} + + context "the acceptance of an invitation" do + it "should create the person with the passed in params" do + Person.count.should be 0 + u = invited_user.accept_invitation!(:invitation_token => "abc", + :username => "user", + :password => "secret", + :password_confirmation => "secret", + :person => {:profile => {:first_name => "Bob", + :last_name => "Smith"}} ) + Person.count.should be 1 + u.person.profile.first_name.should == "Bob" + end + end + + +end + +def create_user_with_invitation(invitation_token, attributes={}) + user = User.new({:password => nil, :password_confirmation => nil}.update(attributes)) + #puts user.inspect + #user.skip_confirmation! + user.invitation_token = invitation_token + user.invitation_sent_at = Time.now.utc + user.save(:validate => false) + user +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 100b9ff39..dfb0f685c 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -6,6 +6,11 @@ require 'spec_helper' describe User do let(:user) { Factory(:user) } + let(:aspect) { user.aspect(:name => 'heroes') } + let(:user2) { Factory(:user) } + let(:aspect2) { user2.aspect(:name => 'stuff') } + let(:user3) { Factory(:user) } + let(:aspect3) { user3.aspect(:name => 'stuff') } describe "validations" do it "downcases the username" do @@ -38,9 +43,6 @@ describe User do end context 'aspects' do - let(:aspect) { user.aspect(:name => 'heroes') } - let(:user2) { Factory(:user) } - let(:aspect2) { user2.aspect(:name => 'stuff') } it 'should delete an empty aspect' do user.drop_aspect(aspect) @@ -48,11 +50,63 @@ describe User do end it 'should not delete an aspect with friends' do - friend_users(user, Aspect.find_by_id(aspect.id), user2, Aspect.find_by_id(aspect2.id)) + friend_users(user, aspect, user2, aspect2) aspect.reload proc{user.drop_aspect(aspect)}.should raise_error /Aspect not empty/ user.aspects.include?(aspect).should == true end end + context 'account removal' do + before do + friend_users(user, aspect, user2, aspect2) + friend_users(user, aspect, user3, aspect3) + end + + it 'should unfriend everyone' do + user.should_receive(:unfriend_everyone) + user.destroy + end + + it 'should remove person' do + user.should_receive(:remove_person) + user.destroy + end + + describe '#remove_person' do + it 'should remove the person object' do + person = user.person + user.destroy + person.reload + person.should be nil + end + + it 'should remove the posts' do + message = user.post(:status_message, :message => "hi", :to => aspect.id) + user.reload + user.destroy + proc{ message.reload }.should raise_error /does not exist/ + end + end + + describe '#unfriend_everyone' do + + before do + user3.delete + end + + it 'should send retractions to remote poeple' do + user.should_receive(:unfriend).once + user.destroy + end + + it 'should unfriend local people' do + user2.friends.count.should be 1 + user.destroy + user2.reload + user2.friends.count.should be 0 + end + end + end + end