bullet_train 1.0.1 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ module Account::Invitations::ControllerBase
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ # the 'accept' action isn't covered by cancancan, because simply having the
6
+ # link is authorization enough to claim membership on a team. see `#accept`
7
+ # for more information.
8
+ account_load_and_authorize_resource :invitation, :team, except: [:show]
9
+
10
+ # we skip the onboarding requirement for users claiming and invitation.
11
+ # this way the invitation gets accepted after they complete the devise
12
+ # workflow, but before they're prompted to complete their onboarding.
13
+ skip_before_action :ensure_onboarding_is_complete_and_set_next_step, only: :accept
14
+ skip_before_action :authenticate_user!, only: :accept
15
+ end
16
+
17
+ # GET /invitations
18
+ # GET /invitations.json
19
+ def index
20
+ redirect_to [:account, @team, :memberships]
21
+ end
22
+
23
+ # GET /invitations/1
24
+ # GET /invitations/1.json
25
+ def show
26
+ # it's important that we only allow invitations to be shown via their uuid,
27
+ # otherwise team members can just step the id in the url to claim an
28
+ # invitation that would escalate their privileges.
29
+ @invitation = Invitation.find_by(uuid: params[:id])
30
+ unless @invitation
31
+ raise I18n.t("global.notifications.not_found")
32
+ end
33
+ @team = @invitation.team
34
+
35
+ # backfill these objects for the locale magic, since we didn't use `account_load_and_authorize_resource`.
36
+ @child_object = @invitation
37
+ @parent_object = @team
38
+
39
+ render layout: "devise"
40
+ end
41
+
42
+ # POST /invitations/1/accept
43
+ # POST /invitations/1/accept.json
44
+ def accept
45
+ # unless the user is signed in.
46
+ if !current_user.present?
47
+
48
+ # keep track of the uuid of the invitation so we can reload it
49
+ # after they sign up. at this point we don't even know if it's
50
+ # valid, but that's fine.
51
+ session[:invitation_uuid] = params[:id]
52
+
53
+ # also, we'll queue devise up to return to the invitation url after a sign in.
54
+ session["user_return_to"] = request.path
55
+
56
+ # assume the user needs to create an account.
57
+ # this is not the default for devise, but a sensible default here.
58
+ redirect_to new_user_registration_path
59
+
60
+ else
61
+
62
+ @invitation = Invitation.find_by(uuid: params[:id])
63
+
64
+ if @invitation
65
+ @team = @invitation.team
66
+ if @invitation.is_for?(current_user) || request.post?
67
+ @invitation.accept_for(current_user)
68
+ redirect_to account_dashboard_path, notice: I18n.t("invitations.notifications.welcome", team_name: @team.name)
69
+ else
70
+ redirect_to account_invitation_path(@invitation.uuid)
71
+ end
72
+ else
73
+ redirect_to account_dashboard_path
74
+ end
75
+
76
+ end
77
+ end
78
+
79
+ # GET /invitations/new
80
+ def new
81
+ @invitation.build_membership
82
+ @cancel_path = only_allow_path(params[:cancel_path])
83
+ end
84
+
85
+ # POST /invitations
86
+ # POST /invitations.json
87
+ def create
88
+ @invitation.membership.team = current_team
89
+ # this allows notifications to be sent to a user before they've accepted their invitation.
90
+ @invitation.membership.user_email = @invitation.email
91
+ @invitation.from_membership = current_membership
92
+ respond_to do |format|
93
+ if @invitation.save
94
+ format.html { redirect_to account_team_invitations_path(@team), notice: I18n.t("invitations.notifications.created") }
95
+ format.json { render :show, status: :created, location: [:account, @team, @invitation] }
96
+ else
97
+ format.html { render :new, status: :unprocessable_entity }
98
+ format.json { render json: @invitation.errors, status: :unprocessable_entity }
99
+ end
100
+ end
101
+ end
102
+
103
+ # DELETE /invitations/1
104
+ # DELETE /invitations/1.json
105
+ def destroy
106
+ @invitation.destroy
107
+ respond_to do |format|
108
+ format.html { redirect_to account_team_invitations_path(@team), notice: I18n.t("invitations.notifications.destroyed") }
109
+ format.json { head :no_content }
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def manageable_role_keys
116
+ helpers.current_membership.manageable_roles.map(&:key)
117
+ end
118
+
119
+ # NOTE this method is only designed to work in the context of creating a invitation.
120
+ # we don't provide any support for updating invitations.
121
+ def invitation_params
122
+ # we use strong params first.
123
+ strong_params = params.require(:invitation).permit(
124
+ :email,
125
+ # 🚅 super scaffolding will insert new fields above this line.
126
+ # 🚅 super scaffolding will insert new arrays above this line.
127
+ membership_attributes: [
128
+ :user_first_name,
129
+ :user_last_name,
130
+ role_ids: []
131
+ ],
132
+ )
133
+
134
+ # after that, we have to be more careful how we assign the roles.
135
+ # we can't let users assign roles to an invitation that they don't have permission
136
+ # to assign, but they do have permission to assign some to other team members.
137
+ if params[:invitation] && params[:invitation][:role_ids].present?
138
+
139
+ # ensure the list of role keys from the form only includes keys that they're allowed to assign.
140
+ assignable_role_keys_from_the_form = params[:invitation][:role_ids].map(&:to_i) & manageable_role_keys
141
+
142
+ strong_params[:role_ids] = assignable_role_keys_from_the_form
143
+
144
+ end
145
+
146
+ # 🚅 super scaffolding will insert processing for new fields above this line.
147
+
148
+ strong_params
149
+ end
150
+ end
@@ -0,0 +1,136 @@
1
+ module Account::Memberships::ControllerBase
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ account_load_and_authorize_resource :membership, :team, member_actions: [:demote, :promote, :reinvite], collection_actions: [:search]
6
+ end
7
+
8
+ def index
9
+ unless @memberships.count > 0
10
+ redirect_to account_team_invitations_path(@team), notice: I18n.t("memberships.notifications.no_members")
11
+ end
12
+ end
13
+
14
+ def search
15
+ # TODO This is a particularly crazy example where we're doing the search logic ourselves in SQL.
16
+ # In the future, I could see us replacing this with a recommended example using Elasticsearch and the `searchkick` Ruby Gem.
17
+ limit = params[:limit] || 100
18
+ page = [params[:page].to_i, 1].max # Ensure we never have a negative or zero page value
19
+ search_term = "%#{params[:search]&.upcase}%"
20
+ offset = (page - 1) * limit
21
+ # Currently we're only searching on user.first_name, user.last_name, memberships.user_first_name and memberships.user_last_name. Should we also search on the email address?
22
+ # This query could use impromement. Currently if you search for "Ad Pal" you wouldn't find a user "Adam Pallozzi"
23
+ query = "UPPER(first_name) LIKE :search_term OR UPPER(last_name) LIKE :search_term OR UPPER(user_first_name) LIKE :search_term OR UPPER(user_last_name) LIKE :search_term"
24
+ # We're using left outer join here because we may get memberships that don't belong to a membership yet
25
+ memberships = @team.memberships.accessible_by(current_ability, :show).left_outer_joins(:user).where(query, search_term: search_term)
26
+ total_results = memberships.size
27
+ # the Areal.sql(LOWER(COALESCE...)) part means that if the user record doesn't exist or if there are records that start with a lower case letter, we will still sort everything correctly using the user.first_name instead.
28
+ memberships_array = memberships.limit(limit).offset(offset).order(Arel.sql("LOWER(COALESCE(first_name, user_first_name) )")).map { |membership| {id: membership.id, text: membership.label_string.to_s} }
29
+ results = {results: memberships_array, pagination: {more: (total_results > page * limit)}}
30
+ render json: results.to_json
31
+ end
32
+
33
+ def show
34
+ end
35
+
36
+ def edit
37
+ end
38
+
39
+ # PATCH/PUT /account/memberships/:id
40
+ # PATCH/PUT /account/memberships/:id.json
41
+ def update
42
+ respond_to do |format|
43
+ if @membership.update(membership_params)
44
+ format.html { redirect_to [:account, @membership], notice: I18n.t("memberships.notifications.updated") }
45
+ format.json { render :show, status: :ok, location: [:account, @membership] }
46
+ else
47
+ format.html { render :edit, status: :unprocessable_entity }
48
+ format.json { render json: @membership.errors, status: :unprocessable_entity }
49
+ end
50
+ rescue RemovingLastTeamAdminException => _
51
+ format.html { redirect_to [:account, @team, :memberships], alert: I18n.t("memberships.notifications.cant_demote") }
52
+ format.json { render json: {exception: I18n.t("memberships.notifications.cant_demote")}, status: :unprocessable_entity }
53
+ end
54
+ end
55
+
56
+ def demote
57
+ @membership.roles.delete Role.admin
58
+ redirect_to account_team_memberships_path(@team)
59
+ rescue RemovingLastTeamAdminException => _
60
+ redirect_to account_team_memberships_path(@team), alert: I18n.t("memberships.notifications.cant_demote")
61
+ end
62
+
63
+ def promote
64
+ @membership.roles << Role.admin unless @membership.roles.include?(Role.admin)
65
+ redirect_to account_team_memberships_path(@team)
66
+ end
67
+
68
+ def destroy
69
+ # Instead of destroying the membership, we nullify the user_id and use the membership record as a 'Tombstone' for referencing past associations (eg message at-mentions and Scaffolding::CompletelyConcrete::TangibleThings::Assignment)
70
+
71
+ user_was = @membership.user
72
+ @membership.nullify_user
73
+
74
+ if user_was == current_user
75
+ # if a user removes themselves from a team, we'll have to send them to their dashboard.
76
+ redirect_to account_dashboard_path, notice: I18n.t("memberships.notifications.you_removed_yourself", team_name: @team.name)
77
+ else
78
+ redirect_to [:account, @team, :memberships], notice: I18n.t("memberships.notifications.destroyed")
79
+ end
80
+ rescue RemovingLastTeamAdminException
81
+ redirect_to account_team_memberships_path(@team), alert: I18n.t("memberships.notifications.cant_remove")
82
+ end
83
+
84
+ def reinvite
85
+ @invitation = Invitation.new(membership: @membership, team: @team, email: @membership.user_email, from_membership: current_membership)
86
+ if @invitation.save
87
+ redirect_to [:account, @team, :memberships], notice: I18n.t("account.memberships.notifications.reinvited")
88
+ else
89
+ redirect_to [:account, @team, :memberships], notice: "There was an error creating the invitation (#{@invitation.errors.full_messages.to_sentence})"
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def manageable_role_keys
96
+ helpers.current_membership.manageable_roles.map(&:key)
97
+ end
98
+
99
+ # NOTE this method is only designed to work in the context of updating a membership.
100
+ # we don't provide any support for creating memberships other than by an invitation.
101
+ def membership_params
102
+ # we use strong params first.
103
+ strong_params = params.require(:membership).permit(
104
+ :user_first_name,
105
+ :user_last_name,
106
+ :user_profile_photo_id,
107
+ # 🚅 super scaffolding will insert new fields above this line.
108
+ # 🚅 super scaffolding will insert new arrays above this line.
109
+ )
110
+
111
+ # after that, we have to be more careful how we update the roles.
112
+ # we can't let users remove roles from a membership that they don't have permission
113
+ # to remove, but we want to allow them to add or remove other roles they do have
114
+ # permission to assign to other team members.
115
+ if params[:membership] && params[:membership][:role_ids].present?
116
+
117
+ # first, start with the list of role keys already assigned to this membership.
118
+ existing_role_keys = @membership.role_ids
119
+
120
+ # generate a list of role keys we can't allow the current user to remove from this membership.
121
+ existing_role_keys_that_are_unmanageable = existing_role_keys - manageable_role_keys
122
+
123
+ # now let's ensure the list of role keys from the form only includes keys that they're allowed to assign.
124
+ assignable_role_keys_from_the_form = params[:membership][:role_ids].map(&:to_s) & manageable_role_keys
125
+
126
+ # any role keys that are manageable by the current user have to then come from the form data,
127
+ # otherwise we can assume they were removed by being unchecked.
128
+ strong_params[:role_ids] = existing_role_keys_that_are_unmanageable + assignable_role_keys_from_the_form
129
+
130
+ end
131
+
132
+ # 🚅 super scaffolding will insert processing for new fields above this line.
133
+
134
+ strong_params
135
+ end
136
+ end
@@ -0,0 +1,67 @@
1
+ module Account::Onboarding::UserDetails::ControllerBase
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ layout "devise"
6
+ load_and_authorize_resource class: "User"
7
+
8
+ # this is because cancancan doesn't let us set the instance variable name above.
9
+ before_action do
10
+ @user = @user_detail
11
+ end
12
+ end
13
+
14
+ # GET /users/1/edit
15
+ def edit
16
+ flash[:notice] = nil
17
+ end
18
+
19
+ # PATCH/PUT /users/1
20
+ # PATCH/PUT /users/1.json
21
+ def update
22
+ respond_to do |format|
23
+ if @user.update(user_params)
24
+ # if you update your own user account, devise will normally kick you out, so we do this instead.
25
+ bypass_sign_in current_user.reload
26
+
27
+ if @user.details_provided?
28
+ format.html { redirect_to account_team_path(@user.teams.first), notice: "" }
29
+ else
30
+ format.html {
31
+ flash[:error] = I18n.t("global.notifications.all_fields_required")
32
+ redirect_to edit_account_onboarding_user_detail_path(@user)
33
+ }
34
+ end
35
+
36
+ format.json { render :show, status: :ok, location: [:account, @user] }
37
+ else
38
+ format.html { render :edit, status: :unprocessable_entity }
39
+ format.json { render json: @user.errors, status: :unprocessable_entity }
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # Never trust parameters from the scary internet, only allow the white list through.
47
+ def user_params
48
+ permitted_attributes = [
49
+ :first_name,
50
+ :last_name,
51
+ :time_zone,
52
+ # 🚅 super scaffolding will insert new fields above this line.
53
+ ]
54
+
55
+ permitted_hash = {
56
+ # 🚅 super scaffolding will insert new arrays above this line.
57
+ }
58
+
59
+ if can? :edit, @user.current_team
60
+ permitted_hash[:current_team_attributes] = [:id, :name]
61
+ end
62
+
63
+ params.require(:user).permit(permitted_attributes, permitted_hash)
64
+
65
+ # 🚅 super scaffolding will insert processing for new fields above this line.
66
+ end
67
+ end
@@ -0,0 +1,69 @@
1
+ module Account::Onboarding::UserEmail::ControllerBase
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ layout "devise"
6
+ load_and_authorize_resource class: "User"
7
+
8
+ # this is because cancancan doesn't let us set the instance variable name above.
9
+ before_action do
10
+ @user = @user_email
11
+ end
12
+ end
13
+
14
+ # GET /users/1/edit
15
+ def edit
16
+ flash[:notice] = nil
17
+ if @user.email_is_oauth_placeholder?
18
+ @user.email = nil
19
+ end
20
+ end
21
+
22
+ # PATCH/PUT /users/1
23
+ # PATCH/PUT /users/1.json
24
+ def update
25
+ respond_to do |format|
26
+ if @user.update(user_params)
27
+ # if you update your own user account, devise will normally kick you out, so we do this instead.
28
+ bypass_sign_in current_user.reload
29
+
30
+ if !@user.email_is_oauth_placeholder?
31
+ @user.send_welcome_email
32
+ format.html { redirect_to account_team_path(@user.teams.first), notice: "" }
33
+ else
34
+ format.html {
35
+ flash[:error] = I18n.t("global.notifications.all_fields_required")
36
+ redirect_to edit_account_onboarding_user_detail_path(@user)
37
+ }
38
+ end
39
+
40
+ format.json { render :show, status: :ok, location: [:account, @user] }
41
+ else
42
+
43
+ # this is just checking whether the error on the email field is taking the email
44
+ # address is already taken.
45
+ @email_taken = begin
46
+ @user.errors.details[:email].select { |error| error[:error] == :taken }.any?
47
+ rescue
48
+ false
49
+ end
50
+
51
+ format.html { render :edit, status: :unprocessable_entity }
52
+ format.json { render json: @user.errors, status: :unprocessable_entity }
53
+ end
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # Never trust parameters from the scary internet, only allow the white list through.
60
+ def user_params
61
+ params.require(:user).permit(
62
+ :email,
63
+ # 🚅 super scaffolding will insert new fields above this line.
64
+ # 🚅 super scaffolding will insert new arrays above this line.
65
+ )
66
+
67
+ # 🚅 super scaffolding will insert processing for new fields above this line.
68
+ end
69
+ end
@@ -0,0 +1,126 @@
1
+ module Account::Teams::ControllerBase
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ load_and_authorize_resource :team, class: "Team", prepend: true
6
+
7
+ prepend_before_action do
8
+ if params["action"] == "new"
9
+ current_user.current_team = nil
10
+ end
11
+ end
12
+
13
+ before_action :enforce_invitation_only, only: [:create]
14
+
15
+ before_action do
16
+ # for magic locales.
17
+ @child_object = @team
18
+ end
19
+ end
20
+
21
+ # GET /teams
22
+ # GET /teams.json
23
+ def index
24
+ # if a user doesn't have multiple teams, we try to simplify the team ui/ux
25
+ # as much as possible. links to this page should go to the current team
26
+ # dashboard. however, some other links to this page are actually in branch
27
+ # logic and will not display at all. instead, users will be linked to the
28
+ # "new team" page. (see the main account sidebar menu for an example of
29
+ # this.)
30
+ unless current_user.multiple_teams?
31
+ redirect_to account_team_path(current_team)
32
+ end
33
+ end
34
+
35
+ # POST /teams/1/switch
36
+ def switch_to
37
+ current_user.current_team = @team
38
+ current_user.save
39
+ redirect_to account_dashboard_path
40
+ end
41
+
42
+ # GET /teams/1
43
+ # GET /teams/1.json
44
+ def show
45
+ # I don't think this is the best place to close the loop on the onboarding process, but practically speaking it's
46
+ # the easiest place to implement this at the moment, because all the onboarding steps redirect here on success.
47
+ if session[:after_onboarding_url].present?
48
+ redirect_to session.delete(:after_onboarding_url)
49
+ end
50
+
51
+ current_user.current_team = @team
52
+ current_user.save
53
+ end
54
+
55
+ # GET /teams/new
56
+ def new
57
+ render :new, layout: "devise"
58
+ end
59
+
60
+ # GET /teams/1/edit
61
+ def edit
62
+ end
63
+
64
+ # POST /teams
65
+ # POST /teams.json
66
+ def create
67
+ @team = Team.new(team_params)
68
+
69
+ respond_to do |format|
70
+ if @team.save
71
+
72
+ # also make the creator of the team the default admin.
73
+ @team.memberships.create(user: current_user, roles: [Role.admin])
74
+
75
+ current_user.current_team = @team
76
+ current_user.former_user = false
77
+ current_user.save
78
+
79
+ format.html { redirect_to [:account, @team], notice: I18n.t("teams.notifications.created") }
80
+ format.json { render :show, status: :created, location: [:account, @team] }
81
+ else
82
+ format.html { render :new, layout: "devise" }
83
+ format.json { render json: @team.errors, status: :unprocessable_entity }
84
+ end
85
+ end
86
+ end
87
+
88
+ # PATCH/PUT /teams/1
89
+ # PATCH/PUT /teams/1.json
90
+ def update
91
+ respond_to do |format|
92
+ if @team.update(team_params)
93
+ format.html { redirect_to [:account, @team], notice: I18n.t("teams.notifications.updated") }
94
+ format.json { render :show, status: :ok, location: [:account, @team] }
95
+ else
96
+ format.html { render :edit, status: :unprocessable_entity }
97
+ format.json { render json: @team.errors, status: :unprocessable_entity }
98
+ end
99
+ end
100
+ end
101
+
102
+ # # DELETE /teams/1
103
+ # # DELETE /teams/1.json
104
+ # def destroy
105
+ # @team.destroy
106
+ # respond_to do |format|
107
+ # format.html { redirect_to account_teams_url, notice: 'Team was successfully destroyed.' }
108
+ # format.json { head :no_content }
109
+ # end
110
+ # end
111
+
112
+ private
113
+
114
+ # Never trust parameters from the scary internet, only allow the white list through.
115
+ def team_params
116
+ params.require(:team).permit(
117
+ :name,
118
+ :time_zone,
119
+ :locale,
120
+ # 🚅 super scaffolding will insert new fields above this line.
121
+ # 🚅 super scaffolding will insert new arrays above this line.
122
+ )
123
+
124
+ # 🚅 super scaffolding will insert processing for new fields above this line.
125
+ end
126
+ end
@@ -0,0 +1,63 @@
1
+ module Account::Users::ControllerBase
2
+ extend ActiveSupport::Concern
3
+
4
+ include do
5
+ load_and_authorize_resource
6
+
7
+ before_action do
8
+ # for magic locales.
9
+ @child_object = @user
10
+ end
11
+ end
12
+
13
+ # GET /account/users/1/edit
14
+ def edit
15
+ end
16
+
17
+ # GET /account/users/1
18
+ def show
19
+ end
20
+
21
+ def updating_password?
22
+ params[:user].key?(:password)
23
+ end
24
+
25
+ # PATCH/PUT /account/users/1
26
+ # PATCH/PUT /account/users/1.json
27
+ def update
28
+ respond_to do |format|
29
+ if updating_password? ? @user.update_with_password(user_params) : @user.update_without_password(user_params)
30
+ # if you update your own user account, devise will normally kick you out, so we do this instead.
31
+ bypass_sign_in current_user.reload
32
+ format.html { redirect_to [:edit, :account, @user], notice: t("users.notifications.updated") }
33
+ format.json { render :show, status: :ok, location: [:account, @user] }
34
+ else
35
+ format.html { render :edit, status: :unprocessable_entity }
36
+ format.json { render json: @user.errors, status: :unprocessable_entity }
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Never trust parameters from the scary internet, only allow the white list through.
44
+ def user_params
45
+ # TODO enforce permissions on updating the user's team name.
46
+ params.require(:user).permit(
47
+ :email,
48
+ :first_name,
49
+ :last_name,
50
+ :time_zone,
51
+ :current_password,
52
+ :password,
53
+ :password_confirmation,
54
+ :profile_photo_id,
55
+ :locale,
56
+ # 🚅 super scaffolding will insert new fields above this line.
57
+ current_team_attributes: [:name],
58
+ # 🚅 super scaffolding will insert new arrays above this line.
59
+ )
60
+
61
+ # 🚅 super scaffolding will insert processing for new fields above this line.
62
+ end
63
+ end
@@ -0,0 +1,46 @@
1
+ module Invitations::Base
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ belongs_to :team
6
+ belongs_to :from_membership, class_name: "Membership"
7
+ has_one :membership, dependent: :nullify
8
+ has_many :roles, through: :membership
9
+
10
+ accepts_nested_attributes_for :membership
11
+
12
+ validates :email, presence: true
13
+
14
+ before_create :generate_uuid
15
+ after_create :set_added_by_membership
16
+ after_create :send_invitation_email
17
+ end
18
+
19
+ def set_added_by_membership
20
+ membership.update(added_by: from_membership)
21
+ end
22
+
23
+ def send_invitation_email
24
+ UserMailer.invited(uuid).deliver_later
25
+ end
26
+
27
+ def generate_uuid
28
+ self.uuid = SecureRandom.hex
29
+ end
30
+
31
+ def accept_for(user)
32
+ User.transaction do
33
+ user.memberships << membership
34
+ user.update(current_team: team, former_user: false)
35
+ destroy
36
+ end
37
+ end
38
+
39
+ def name
40
+ I18n.t("invitations.values.name", team_name: team.name)
41
+ end
42
+
43
+ def is_for?(user)
44
+ user.email.downcase.strip == email.downcase.strip
45
+ end
46
+ end