mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Family invites (#1397)
* 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
This commit is contained in:
parent
09b269273a
commit
793bd852a0
26 changed files with 502 additions and 45 deletions
|
@ -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
|
||||
|
||||
|
|
42
app/controllers/invitations_controller.rb
Normal file
42
app/controllers/invitations_controller.rb
Normal file
|
@ -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
|
|
@ -1,7 +1,7 @@
|
|||
class OnboardingsController < ApplicationController
|
||||
layout "application"
|
||||
|
||||
before_action :set_user
|
||||
before_action :load_invitation
|
||||
|
||||
def show
|
||||
end
|
||||
|
@ -13,7 +13,12 @@ class OnboardingsController < ApplicationController
|
|||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
def load_invitation
|
||||
@invitation = Invitation.accepted.most_recent_for_email(Current.user.email)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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_user
|
||||
@user = User.new user_params.except(:invite_code)
|
||||
def set_invitation
|
||||
token = params[:invitation]
|
||||
token ||= params[:user][:invitation] if params[:user].present?
|
||||
@invitation = Invitation.pending.find_by(token: token)
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
|
||||
def set_user
|
||||
@user = User.new user_params.except(:invite_code, :invitation)
|
||||
end
|
||||
|
||||
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
|
||||
|
|
|
@ -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
app/helpers/invitations_helper.rb
Normal file
2
app/helpers/invitations_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
module InvitationsHelper
|
||||
end
|
11
app/mailers/invitation_mailer.rb
Normal file
11
app/mailers/invitation_mailer.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
37
app/models/invitation.rb
Normal file
37
app/models/invitation.rb
Normal file
|
@ -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
app/views/invitation_mailer/invite_email.html.erb
Normal file
11
app/views/invitation_mailer/invite_email.html.erb
Normal file
|
@ -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
app/views/invitations/new.html.erb
Normal file
20
app/views/invitations/new.html.erb
Normal file
|
@ -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 %>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
@ -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>
|
||||
<% 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") %>
|
||||
|
||||
<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>
|
||||
<%= family_form.select :country,
|
||||
country_options,
|
||||
{ label: t(".country") }, required: true %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form.submit t(".submit") %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<%
|
||||
header_title t(".title")
|
||||
header_title @invitation ? t(".join_family_title", family: @invitation.family.name) : t(".title")
|
||||
%>
|
||||
|
||||
<% if self_hosted_first_login? %>
|
||||
|
@ -7,14 +7,29 @@
|
|||
<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: "you@example.com", label: true %>
|
||||
<%= form.email_field :email,
|
||||
autofocus: false,
|
||||
autocomplete: "email",
|
||||
required: "required",
|
||||
placeholder: "you@example.com",
|
||||
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 %>
|
||||
|
|
|
@ -34,15 +34,60 @@
|
|||
<div class="px-4 py-2">
|
||||
<p class="uppercase text-xs text-gray-500 font-medium"><%= Current.family.name %> · <%= 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 @pending_invitations.any? %>
|
||||
<% @pending_invitations.each do |invitation| %>
|
||||
<div class="flex gap-2 items-center justify-between bg-white p-4 border border-alpha-black-25 rounded-lg">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="w-9 h-9 shrink-0">
|
||||
<div class="text-white w-full h-full bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= invitation.email[0] %></div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<p class="text-gray-900 font-medium text-sm"><%= invitation.email %></p>
|
||||
<div class="rounded-md bg-gray-100 px-1.5 py-0.5">
|
||||
<p class="uppercase text-gray-500 font-medium text-xs"><%= t(".pending") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% if self_hosted? %>
|
||||
<div class="flex items-center gap-2" data-controller="clipboard">
|
||||
<p class="text-gray-500 text-sm"><%= t(".invitation_link") %></p>
|
||||
<span data-clipboard-target="source" class="hidden"><%= accept_invitation_url(invitation.token) %></span>
|
||||
<input type="text"
|
||||
readonly
|
||||
value="<%= accept_invitation_url(invitation.token) %>"
|
||||
class="text-sm bg-gray-50 px-2 py-1 rounded border border-gray-200 w-72">
|
||||
<button data-action="clipboard#copy" class="text-gray-500 hover:text-gray-700">
|
||||
<span data-clipboard-target="iconDefault">
|
||||
<%= lucide_icon "copy", class: "w-5 h-5" %>
|
||||
</span>
|
||||
<span class="hidden" data-clipboard-target="iconSuccess">
|
||||
<%= lucide_icon "check", class: "w-5 h-5" %>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% 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 %>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue