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 8 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
43 changes: 43 additions & 0 deletions app/controllers/invitations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
class InvitationsController < ApplicationController
skip_authentication only: :accept
def new
@invitation = Invitation.new
end

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

if @invitation.role == "admin" && !Current.user.admin?
@invitation.role = "member"
end

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

redirect_to settings_profile_path
end

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

private

def invitation_params
base_params = params.require(:invitation).permit(:email)

if params[:invitation][:role].in?(%w[admin member])
base_params[:role] = params[:invitation][:role]
else
base_params[:role] = "member"
end

base_params
end
end
16 changes: 6 additions & 10 deletions app/controllers/onboardings_controller.rb
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
class OnboardingsController < ApplicationController
layout "application"

before_action :set_user

def show
end

def profile
end

def preferences
end
before_action :load_invitation

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: 25 additions & 10 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,51 @@ class RegistrationsController < ApplicationController
layout "auth"

before_action :set_user, only: :create
before_action :load_invitation, if: :invitation_token?
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 load_invitation
token = params[:invitation] || params.dig(:user, :invitation)
@invitation = Invitation.pending.find_by!(token: token)
end

def invitation_token?
params[:invitation].present? || params.dig(:user, :invitation).present?
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)
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code, :invitation)
end

def claim_invite_code
Expand Down
1 change: 1 addition & 0 deletions app/controllers/settings/profiles_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Settings::ProfilesController < SettingsController
def show
@user = Current.user
@users = Current.family.users.order(:created_at)
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
28 changes: 28 additions & 0 deletions app/models/invitation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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

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 }

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
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) %>
</p>

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

<p><%= t(".expiry_notice", days: 3) %></p>
23 changes: 23 additions & 0 deletions app/views/invitations/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<%= modal_form_wrapper title: t(".title"), subtitle: t(".subtitle") do %>
<%= styled_form_with model: @invitation, class: "space-y-4" 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="flex justify-end gap-2">
<button type="button" class="text-gray-500" data-action="click->modal#close">
<%= t(".cancel") %>
</button>
<%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2" %>
</div>
<% end %>
<% end %>
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 %>
26 changes: 18 additions & 8 deletions app/views/settings/profiles/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,25 @@
<div class="px-4 py-2">
<p class="uppercase text-xs text-gray-500 font-medium"><%= Current.family.name %> &middot; <%= Current.family.users.size %></p>
</div>
<div class="flex gap-2 items-center bg-white p-4 border border-alpha-black-25 rounded-lg">
<div class="mr-1 flex justify-center items-center bg-gray-50 w-8 h-8 rounded-full border border-alpha-black-25">
<p class="uppercase text-xs text-gray-500"><%= Current.user.initial %></p>
<% @users.each do |user| %>
<div class="flex gap-2 items-center bg-white p-4 border border-alpha-black-25 rounded-lg">
<div class="w-9 h-9 shrink-0">
<%= render "settings/user_avatar", user: user %>
</div>
<p class="text-gray-900 font-medium text-sm"><%= user.display_name %></p>
<div class="rounded-md bg-gray-100 px-1.5 py-0.5">
<p class="uppercase text-gray-500 font-medium text-xs"><%= user.role %></p>
</div>
</div>
<p class="text-gray-900 font-medium text-sm"><%= Current.user.display_name %></p>
<div class="rounded-md bg-gray-100 px-1.5 py-0.5">
<p class="uppercase text-gray-500 font-medium text-xs"><%= Current.user.role %></p>
</div>
</div>
<% end %>
<% if Current.user.admin? %>
<%= link_to new_invitation_path,
class: "bg-gray-100 flex items-center justify-center gap-2 text-gray-500 mt-1 hover:bg-gray-200 rounded-lg px-4 py-2 w-full text-center",
data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
<%= t(".invite_member") %>
<% end %>
<% end %>
</div>
</div>
<% end %>
Expand Down
8 changes: 8 additions & 0 deletions config/locales/mailers/invitation_mailer/en.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
en:
invitation_mailer:
invite_email:
subject: "%{inviter} has invited you to join their household"
greeting: "You've been invited!"
body: "%{inviter} has invited you to join their household '%{family}' as a %{role}."
accept_button: "Accept Invitation"
expiry_notice: "This invitation will expire in %{days} days."
7 changes: 7 additions & 0 deletions config/locales/views/invitation_mailer/en.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
en:
invitation_mailer:
invite_email:
greeting: "Welcome!"
body: "%{inviter} has invited you to join the %{family} family on Maybe!"
accept_button: "Accept Invitation"
expiry_notice: "This invitation will expire in %{days} days"
15 changes: 15 additions & 0 deletions config/locales/views/invitations/en.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
en:
invitations:
create:
success: "Invitation sent successfully"
failure: "Could not send invitation"
new:
title: Invite Someone
subtitle: Send an invitation to join your family
email_placeholder: Enter email address
email_label: Email Address
role_member: Member
role_admin: Administrator
role_label: Role
cancel: Cancel
submit: Send Invitation
7 changes: 5 additions & 2 deletions config/locales/views/registrations/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ en:
create: Continue
registrations:
create:
failure: Invalid input, please try again.
invalid_invite_code: Invalid invite code, please try again.
success: You have signed up successfully.
new:
submit: Create account
title: Create an account
title: Create your account
join_family_title: "Join %{family}"
invitation_message: "%{inviter} has invited you to join as a %{role}"
role_member: "member"
role_admin: "administrator"
welcome_body: To get started, you must sign up for a new account. You will
then be able to configure additional settings within the app.
welcome_title: Welcome to Self Hosted Maybe!
Loading
Loading