Skip to content

Commit

Permalink
Family invites (#1397)
Browse files Browse the repository at this point in the history
* Initial pass at household invites

* Invitee setup

* Clean up add member form

* Lint and other tweaks

* Security cleanup

* Lint

* i18n fixes

* More i18n cleanup

* Show pending invites

* Don't use turbo on the form

* Improved email design

* Basic tests

* Lint

* Update onboardings_controller.rb

* Registration + invite cleanup

* Lint

* Update brakeman.ignore

* Update brakeman.ignore

* Self host invite links

* Test tweaks

* Address missing param error
  • Loading branch information
Shpigford authored Nov 1, 2024
1 parent 09b2692 commit 793bd85
Show file tree
Hide file tree
Showing 26 changed files with 502 additions and 45 deletions.
1 change: 1 addition & 0 deletions app/controllers/concerns/invitable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module Invitable

private
def invite_code_required?
return false if @invitation.present?
self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true"
end

Expand Down
42 changes: 42 additions & 0 deletions app/controllers/invitations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
class InvitationsController < ApplicationController
skip_authentication only: :accept
def new
@invitation = Invitation.new
end

def create
unless Current.user.admin?
flash[:alert] = t(".failure")
redirect_to settings_profile_path
return
end

@invitation = Current.family.invitations.build(invitation_params)
@invitation.inviter = Current.user

if @invitation.save
InvitationMailer.invite_email(@invitation).deliver_later unless self_hosted?
flash[:notice] = t(".success")
else
flash[:alert] = t(".failure")
end

redirect_to settings_profile_path
end

def accept
@invitation = Invitation.find_by!(token: params[:id])

if @invitation.pending?
redirect_to new_registration_path(invitation: @invitation.token)
else
raise ActiveRecord::RecordNotFound
end
end

private

def invitation_params
params.require(:invitation).permit(:email, :role)
end
end
7 changes: 6 additions & 1 deletion app/controllers/onboardings_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class OnboardingsController < ApplicationController
layout "application"

before_action :set_user
before_action :load_invitation

def show
end
Expand All @@ -13,7 +13,12 @@ def preferences
end

private

def set_user
@user = Current.user
end

def load_invitation
@invitation = Invitation.accepted.most_recent_for_email(Current.user.email)
end
end
35 changes: 24 additions & 11 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,49 @@ class RegistrationsController < ApplicationController
layout "auth"

before_action :set_user, only: :create
before_action :set_invitation
before_action :claim_invite_code, only: :create, if: :invite_code_required?

def new
@user = User.new
@user = User.new(email: @invitation&.email)
end

def create
family = Family.new
@user.family = family
@user.role = :admin
if @invitation
@user.family = @invitation.family
@user.role = @invitation.role
@user.email = @invitation.email
else
family = Family.new
@user.family = family
@user.role = :admin
end

if @user.save
Category.create_default_categories(@user.family)
@invitation&.update!(accepted_at: Time.current)
Category.create_default_categories(@user.family) unless @invitation
@session = create_session_for(@user)
flash[:notice] = t(".success")
redirect_to root_path
redirect_to root_path, notice: t(".success")
else
flash[:alert] = t(".failure")
render :new, status: :unprocessable_entity
end
end

private

def set_invitation
token = params[:invitation]
token ||= params[:user][:invitation] if params[:user].present?
@invitation = Invitation.pending.find_by(token: token)
end

def set_user
@user = User.new user_params.except(:invite_code)
@user = User.new user_params.except(:invite_code, :invitation)
end

def user_params
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
def user_params(specific_param = nil)
params = self.params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation)
specific_param ? params[specific_param] : params
end

def claim_invite_code
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/settings/profiles_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
class Settings::ProfilesController < SettingsController
def show
@user = Current.user
@users = Current.family.users.order(:created_at)
@pending_invitations = Current.family.invitations.pending
end
end
2 changes: 2 additions & 0 deletions app/helpers/invitations_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module InvitationsHelper
end
11 changes: 11 additions & 0 deletions app/mailers/invitation_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class InvitationMailer < ApplicationMailer
def invite_email(invitation)
@invitation = invitation
@accept_url = accept_invitation_url(@invitation.token)

mail(
to: @invitation.email,
subject: t(".subject", inviter: @invitation.inviter.display_name)
)
end
end
1 change: 1 addition & 0 deletions app/models/family.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class Family < ApplicationRecord
include Providable

has_many :users, dependent: :destroy
has_many :invitations, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :institutions, dependent: :destroy
Expand Down
37 changes: 37 additions & 0 deletions app/models/invitation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class Invitation < ApplicationRecord
belongs_to :family
belongs_to :inviter, class_name: "User"

validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, presence: true, inclusion: { in: %w[admin member] }
validates :token, presence: true, uniqueness: true
validate :inviter_is_admin

before_validation :generate_token, on: :create
before_create :set_expiration

scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
scope :accepted, -> { where.not(accepted_at: nil) }
scope :most_recent_for_email, ->(email) { where(email: email).order(accepted_at: :desc).first }

def pending?
accepted_at.nil? && expires_at > Time.current
end

private

def generate_token
loop do
self.token = SecureRandom.hex(32)
break unless self.class.exists?(token: token)
end
end

def set_expiration
self.expires_at = 3.days.from_now
end

def inviter_is_admin
inviter.admin?
end
end
11 changes: 11 additions & 0 deletions app/views/invitation_mailer/invite_email.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<h1><%= t(".greeting") %></h1>

<p>
<%= t(".body",
inviter: @invitation.inviter.display_name,
family: @invitation.family.name).html_safe %>
</p>

<%= link_to t(".accept_button"), @accept_url, class: "button" %>

<p class="footer"><%= t(".expiry_notice", days: 3) %></p>
20 changes: 20 additions & 0 deletions app/views/invitations/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<%= modal_form_wrapper title: t(".title"), subtitle: t(".subtitle") do %>
<%= styled_form_with model: @invitation, class: "space-y-4", data: { turbo: false } do |form| %>
<%= form.email_field :email,
required: true,
placeholder: t(".email_placeholder"),
label: t(".email_label") %>
<%= form.select :role,
options_for_select([
[t(".role_member"), "member"],
[t(".role_admin"), "admin"]
]),
{},
{ label: t(".role_label") } %>

<div class="w-full">
<%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2 w-full" %>
</div>
<% end %>
<% end %>
48 changes: 46 additions & 2 deletions app/views/layouts/mailer.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,56 @@
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
/* Email styles need to be inline */
/* Email-safe styles that work across clients */
body {
background-color: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
margin: 0;
padding: 0;
}
.container {
background-color: #ffffff;
border-radius: 8px;
margin: 20px auto;
max-width: 600px;
padding: 32px;
text-align: center;
}
h1 {
color: #1e293b;
font-size: 24px;
margin-bottom: 24px;
}
p {
color: #475569;
font-size: 16px;
margin-bottom: 16px;
}
.button {
background-color: #3b82f6;
border-radius: 6px;
color: #ffffff;
display: inline-block;
font-weight: 600;
margin: 16px 0;
padding: 12px 24px;
text-decoration: none;
}
.footer {
color: #64748b;
font-size: 14px;
margin-top: 32px;
text-align: center;
}
</style>
</head>

<body>
<%= yield %>
<div class="container">
<%= yield %>
</div>
</body>
</html>
24 changes: 13 additions & 11 deletions app/views/onboardings/profile.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
</div>

<%= styled_form_with model: @user do |form| %>
<%= form.hidden_field :redirect_to, value: "onboarding_preferences" %>
<%= form.hidden_field :redirect_to, value: @invitation ? "home" : "onboarding_preferences" %>
<%= form.hidden_field :onboarded_at, value: Time.current if @invitation %>

<div class="space-y-4 mb-4">
<p class="text-gray-500 text-xs"><%= t(".profile_image") %></p>
Expand All @@ -20,16 +21,17 @@
<%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name"), container_class: "bg-white w-1/2", required: true %>
<%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name"), container_class: "bg-white w-1/2", required: true %>
</div>

<div class="space-y-4 mb-4">
<%= form.fields_for :family do |family_form| %>
<%= family_form.text_field :name, placeholder: t(".household_name"), label: t(".household_name") %>
<%= family_form.select :country,
country_options,
{ label: t(".country") }, required: true %>
<% end %>
</div>
<% unless @invitation %>
<div class="space-y-4 mb-4">
<%= form.fields_for :family do |family_form| %>
<%= family_form.text_field :name, placeholder: t(".household_name"), label: t(".household_name") %>
<%= family_form.select :country,
country_options,
{ label: t(".country") }, required: true %>
<% end %>
</div>
<% end %>
<%= form.submit t(".submit") %>
<% end %>
Expand Down
21 changes: 18 additions & 3 deletions app/views/registrations/new.html.erb
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
<%
header_title t(".title")
header_title @invitation ? t(".join_family_title", family: @invitation.family.name) : t(".title")
%>
<% if self_hosted_first_login? %>
<div class="fixed inset-0 w-full h-fit bg-gray-25 p-5 border-b border-alpha-black-200 flex flex-col gap-3 items-center text-center mb-12">
<h2 class="font-bold text-xl"><%= t(".welcome_title") %></h2>
<p class="text-gray-500 text-sm"><%= t(".welcome_body") %></p>
</div>
<% elsif @invitation %>
<div class="space-y-1 mb-6 text-center">
<p class="text-gray-500">
<%= t(".invitation_message",
inviter: @invitation.inviter.display_name,
role: t(".role_#{@invitation.role}")) %>
</p>
</div>
<% end %>
<%= styled_form_with model: @user, url: registration_path, class: "space-y-4" do |form| %>
<%= form.email_field :email, autofocus: false, autocomplete: "email", required: "required", placeholder: "[email protected]", label: true %>
<%= form.email_field :email,
autofocus: false,
autocomplete: "email",
required: "required",
placeholder: "[email protected]",
label: true,
disabled: @invitation.present? %>
<%= form.password_field :password, autocomplete: "new-password", required: "required", label: true %>
<%= form.password_field :password_confirmation, autocomplete: "new-password", required: "required", label: true %>
<% if invite_code_required? %>
<% if invite_code_required? && !@invitation %>
<%= form.text_field :invite_code, required: "required", label: true, value: params[:invite] %>
<% end %>
<%= form.hidden_field :invitation, value: @invitation&.token %>
<%= form.submit t(".submit") %>
<% end %>
Loading

0 comments on commit 793bd85

Please sign in to comment.