Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Family invites #1397

Merged
merged 22 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Shpigford marked this conversation as resolved.
Show resolved Hide resolved

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
Shpigford marked this conversation as resolved.
Show resolved Hide resolved

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