mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +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
|
private
|
||||||
def invite_code_required?
|
def invite_code_required?
|
||||||
|
return false if @invitation.present?
|
||||||
self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true"
|
self_hosted? ? Setting.require_invite_for_signup : ENV["REQUIRE_INVITE_CODE"] == "true"
|
||||||
end
|
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
|
class OnboardingsController < ApplicationController
|
||||||
layout "application"
|
layout "application"
|
||||||
|
|
||||||
before_action :set_user
|
before_action :set_user
|
||||||
|
before_action :load_invitation
|
||||||
|
|
||||||
def show
|
def show
|
||||||
end
|
end
|
||||||
|
@ -13,7 +13,12 @@ class OnboardingsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_user
|
def set_user
|
||||||
@user = Current.user
|
@user = Current.user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def load_invitation
|
||||||
|
@invitation = Invitation.accepted.most_recent_for_email(Current.user.email)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,36 +4,49 @@ class RegistrationsController < ApplicationController
|
||||||
layout "auth"
|
layout "auth"
|
||||||
|
|
||||||
before_action :set_user, only: :create
|
before_action :set_user, only: :create
|
||||||
|
before_action :set_invitation
|
||||||
before_action :claim_invite_code, only: :create, if: :invite_code_required?
|
before_action :claim_invite_code, only: :create, if: :invite_code_required?
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@user = User.new
|
@user = User.new(email: @invitation&.email)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
family = Family.new
|
if @invitation
|
||||||
@user.family = family
|
@user.family = @invitation.family
|
||||||
@user.role = :admin
|
@user.role = @invitation.role
|
||||||
|
@user.email = @invitation.email
|
||||||
|
else
|
||||||
|
family = Family.new
|
||||||
|
@user.family = family
|
||||||
|
@user.role = :admin
|
||||||
|
end
|
||||||
|
|
||||||
if @user.save
|
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)
|
@session = create_session_for(@user)
|
||||||
flash[:notice] = t(".success")
|
redirect_to root_path, notice: t(".success")
|
||||||
redirect_to root_path
|
|
||||||
else
|
else
|
||||||
flash[:alert] = t(".failure")
|
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_user
|
def set_invitation
|
||||||
@user = User.new user_params.except(:invite_code)
|
token = params[:invitation]
|
||||||
|
token ||= params[:user][:invitation] if params[:user].present?
|
||||||
|
@invitation = Invitation.pending.find_by(token: token)
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def set_user
|
||||||
params.require(:user).permit(:name, :email, :password, :password_confirmation, :invite_code)
|
@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
|
end
|
||||||
|
|
||||||
def claim_invite_code
|
def claim_invite_code
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
class Settings::ProfilesController < SettingsController
|
class Settings::ProfilesController < SettingsController
|
||||||
def show
|
def show
|
||||||
@user = Current.user
|
@user = Current.user
|
||||||
|
@users = Current.family.users.order(:created_at)
|
||||||
|
@pending_invitations = Current.family.invitations.pending
|
||||||
end
|
end
|
||||||
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
|
include Providable
|
||||||
|
|
||||||
has_many :users, dependent: :destroy
|
has_many :users, dependent: :destroy
|
||||||
|
has_many :invitations, dependent: :destroy
|
||||||
has_many :tags, dependent: :destroy
|
has_many :tags, dependent: :destroy
|
||||||
has_many :accounts, dependent: :destroy
|
has_many :accounts, dependent: :destroy
|
||||||
has_many :institutions, 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>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<style>
|
<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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<%= yield %>
|
<div class="container">
|
||||||
|
<%= yield %>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -9,7 +9,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= styled_form_with model: @user do |form| %>
|
<%= 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">
|
<div class="space-y-4 mb-4">
|
||||||
<p class="text-gray-500 text-xs"><%= t(".profile_image") %></p>
|
<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 :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 %>
|
<%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name"), container_class: "bg-white w-1/2", required: true %>
|
||||||
</div>
|
</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">
|
<%= family_form.select :country,
|
||||||
<%= form.fields_for :family do |family_form| %>
|
country_options,
|
||||||
<%= family_form.text_field :name, placeholder: t(".household_name"), label: t(".household_name") %>
|
{ label: t(".country") }, required: true %>
|
||||||
|
<% end %>
|
||||||
<%= family_form.select :country,
|
</div>
|
||||||
country_options,
|
<% end %>
|
||||||
{ label: t(".country") }, required: true %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= form.submit t(".submit") %>
|
<%= form.submit t(".submit") %>
|
||||||
<% end %>
|
<% 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? %>
|
<% if self_hosted_first_login? %>
|
||||||
|
@ -7,14 +7,29 @@
|
||||||
<h2 class="font-bold text-xl"><%= t(".welcome_title") %></h2>
|
<h2 class="font-bold text-xl"><%= t(".welcome_title") %></h2>
|
||||||
<p class="text-gray-500 text-sm"><%= t(".welcome_body") %></p>
|
<p class="text-gray-500 text-sm"><%= t(".welcome_body") %></p>
|
||||||
</div>
|
</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 %>
|
<% end %>
|
||||||
|
|
||||||
<%= styled_form_with model: @user, url: registration_path, class: "space-y-4" do |form| %>
|
<%= 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, autocomplete: "new-password", required: "required", label: true %>
|
||||||
<%= form.password_field :password_confirmation, 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] %>
|
<%= form.text_field :invite_code, required: "required", label: true, value: params[:invite] %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= form.hidden_field :invitation, value: @invitation&.token %>
|
||||||
<%= form.submit t(".submit") %>
|
<%= form.submit t(".submit") %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -34,15 +34,60 @@
|
||||||
<div class="px-4 py-2">
|
<div class="px-4 py-2">
|
||||||
<p class="uppercase text-xs text-gray-500 font-medium"><%= Current.family.name %> · <%= Current.family.users.size %></p>
|
<p class="uppercase text-xs text-gray-500 font-medium"><%= Current.family.name %> · <%= Current.family.users.size %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center bg-white p-4 border border-alpha-black-25 rounded-lg">
|
<% @users.each do |user| %>
|
||||||
<div class="mr-1 flex justify-center items-center bg-gray-50 w-8 h-8 rounded-full border border-alpha-black-25">
|
<div class="flex gap-2 items-center bg-white p-4 border border-alpha-black-25 rounded-lg">
|
||||||
<p class="uppercase text-xs text-gray-500"><%= Current.user.initial %></p>
|
<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>
|
</div>
|
||||||
<p class="text-gray-900 font-medium text-sm"><%= Current.user.display_name %></p>
|
<% end %>
|
||||||
<div class="rounded-md bg-gray-100 px-1.5 py-0.5">
|
<% if @pending_invitations.any? %>
|
||||||
<p class="uppercase text-gray-500 font-medium text-xs"><%= Current.user.role %></p>
|
<% @pending_invitations.each do |invitation| %>
|
||||||
</div>
|
<div class="flex gap-2 items-center justify-between bg-white p-4 border border-alpha-black-25 rounded-lg">
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
"type": "controller",
|
"type": "controller",
|
||||||
"class": "AccountsController",
|
"class": "AccountsController",
|
||||||
"method": "show",
|
"method": "show",
|
||||||
"line": 39,
|
"line": 36,
|
||||||
"file": "app/controllers/accounts_controller.rb",
|
"file": "app/controllers/accounts_controller.rb",
|
||||||
"rendered": {
|
"rendered": {
|
||||||
"name": "accounts/show",
|
"name": "accounts/show",
|
||||||
|
@ -72,7 +72,7 @@
|
||||||
"type": "controller",
|
"type": "controller",
|
||||||
"class": "AccountsController",
|
"class": "AccountsController",
|
||||||
"method": "show",
|
"method": "show",
|
||||||
"line": 39,
|
"line": 36,
|
||||||
"file": "app/controllers/accounts_controller.rb",
|
"file": "app/controllers/accounts_controller.rb",
|
||||||
"rendered": {
|
"rendered": {
|
||||||
"name": "accounts/show",
|
"name": "accounts/show",
|
||||||
|
@ -91,6 +91,29 @@
|
||||||
],
|
],
|
||||||
"note": ""
|
"note": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"warning_type": "Mass Assignment",
|
||||||
|
"warning_code": 105,
|
||||||
|
"fingerprint": "aaccd8db0be34afdc88e5af08d91ae2e8b7765dfea2f3fc6e1c37db0adc7b991",
|
||||||
|
"check_name": "PermitAttributes",
|
||||||
|
"message": "Potentially dangerous key allowed for mass assignment",
|
||||||
|
"file": "app/controllers/invitations_controller.rb",
|
||||||
|
"line": 34,
|
||||||
|
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
|
||||||
|
"code": "params.require(:invitation).permit(:email, :role)",
|
||||||
|
"render_path": null,
|
||||||
|
"location": {
|
||||||
|
"type": "method",
|
||||||
|
"class": "InvitationsController",
|
||||||
|
"method": "invitation_params"
|
||||||
|
},
|
||||||
|
"user_input": ":role",
|
||||||
|
"confidence": "Medium",
|
||||||
|
"cwe_id": [
|
||||||
|
915
|
||||||
|
],
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"warning_type": "Cross-Site Scripting",
|
"warning_type": "Cross-Site Scripting",
|
||||||
"warning_code": 2,
|
"warning_code": 2,
|
||||||
|
@ -140,7 +163,7 @@
|
||||||
"type": "controller",
|
"type": "controller",
|
||||||
"class": "AccountsController",
|
"class": "AccountsController",
|
||||||
"method": "show",
|
"method": "show",
|
||||||
"line": 39,
|
"line": 36,
|
||||||
"file": "app/controllers/accounts_controller.rb",
|
"file": "app/controllers/accounts_controller.rb",
|
||||||
"rendered": {
|
"rendered": {
|
||||||
"name": "accounts/show",
|
"name": "accounts/show",
|
||||||
|
@ -194,6 +217,6 @@
|
||||||
"note": ""
|
"note": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updated": "2024-10-17 11:30:15 -0400",
|
"updated": "2024-11-01 09:36:40 -0500",
|
||||||
"brakeman_version": "6.2.1"
|
"brakeman_version": "6.2.2"
|
||||||
}
|
}
|
||||||
|
|
8
config/locales/mailers/invitation_mailer/en.yml
Normal file
8
config/locales/mailers/invitation_mailer/en.yml
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
en:
|
||||||
|
invitation_mailer:
|
||||||
|
invite_email:
|
||||||
|
subject: "%{inviter} has invited you to join their household on Maybe!"
|
||||||
|
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
config/locales/views/invitation_mailer/en.yml
Normal file
7
config/locales/views/invitation_mailer/en.yml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
en:
|
||||||
|
invitation_mailer:
|
||||||
|
invite_email:
|
||||||
|
greeting: "Welcome to Maybe!"
|
||||||
|
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"
|
14
config/locales/views/invitations/en.yml
Normal file
14
config/locales/views/invitations/en.yml
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
en:
|
||||||
|
invitations:
|
||||||
|
create:
|
||||||
|
success: "Invitation sent successfully"
|
||||||
|
failure: "Could not send invitation"
|
||||||
|
new:
|
||||||
|
title: Invite Someone
|
||||||
|
subtitle: Send an invitation to join your family account on Maybe
|
||||||
|
email_placeholder: Enter email address
|
||||||
|
email_label: Email Address
|
||||||
|
role_member: Member
|
||||||
|
role_admin: Administrator
|
||||||
|
role_label: Role
|
||||||
|
submit: Send Invitation
|
|
@ -9,12 +9,15 @@ en:
|
||||||
create: Continue
|
create: Continue
|
||||||
registrations:
|
registrations:
|
||||||
create:
|
create:
|
||||||
failure: Invalid input, please try again.
|
|
||||||
invalid_invite_code: Invalid invite code, please try again.
|
invalid_invite_code: Invalid invite code, please try again.
|
||||||
success: You have signed up successfully.
|
success: You have signed up successfully.
|
||||||
new:
|
new:
|
||||||
submit: Create account
|
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
|
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.
|
then be able to configure additional settings within the app.
|
||||||
welcome_title: Welcome to Self Hosted Maybe!
|
welcome_title: Welcome to Self Hosted Maybe!
|
||||||
|
|
|
@ -41,6 +41,7 @@ en:
|
||||||
theme_title: Theme
|
theme_title: Theme
|
||||||
profiles:
|
profiles:
|
||||||
show:
|
show:
|
||||||
|
invite_member: "Add member"
|
||||||
confirm_delete:
|
confirm_delete:
|
||||||
body: Are you sure you want to permanently delete your account? This action
|
body: Are you sure you want to permanently delete your account? This action
|
||||||
is irreversible.
|
is irreversible.
|
||||||
|
@ -60,6 +61,8 @@ en:
|
||||||
profile_subtitle: Customize how you appear on Maybe
|
profile_subtitle: Customize how you appear on Maybe
|
||||||
profile_title: Profile
|
profile_title: Profile
|
||||||
save: Save
|
save: Save
|
||||||
|
pending: Pending
|
||||||
|
invitation_link: Invitation link
|
||||||
user_avatar_field:
|
user_avatar_field:
|
||||||
accepted_formats: JPG or PNG. 5MB max.
|
accepted_formats: JPG or PNG. 5MB max.
|
||||||
choose: Choose
|
choose: Choose
|
||||||
|
|
|
@ -111,6 +111,10 @@ Rails.application.routes.draw do
|
||||||
resources :exchange_rate_provider_missings, only: :update
|
resources :exchange_rate_provider_missings, only: :update
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :invitations, only: [ :new, :create ] do
|
||||||
|
get :accept, on: :member
|
||||||
|
end
|
||||||
|
|
||||||
# For managing self-hosted upgrades and release notifications
|
# For managing self-hosted upgrades and release notifications
|
||||||
resources :upgrades, only: [] do
|
resources :upgrades, only: [] do
|
||||||
member do
|
member do
|
||||||
|
|
18
db/migrate/20241030222235_create_invitations.rb
Normal file
18
db/migrate/20241030222235_create_invitations.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
class CreateInvitations < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :invitations, id: :uuid do |t|
|
||||||
|
t.string :email
|
||||||
|
t.string :role
|
||||||
|
t.string :token
|
||||||
|
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||||
|
t.references :inviter, null: false, foreign_key: { to_table: :users }, type: :uuid
|
||||||
|
t.datetime :accepted_at
|
||||||
|
t.datetime :expires_at
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :invitations, :token, unique: true
|
||||||
|
add_index :invitations, :email
|
||||||
|
end
|
||||||
|
end
|
22
db/schema.rb
generated
22
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2024_10_30_121302) do
|
ActiveRecord::Schema[7.2].define(version: 2024_10_30_222235) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -119,7 +119,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_30_121302) do
|
||||||
t.boolean "is_active", default: true, null: false
|
t.boolean "is_active", default: true, null: false
|
||||||
t.date "last_sync_date"
|
t.date "last_sync_date"
|
||||||
t.uuid "institution_id"
|
t.uuid "institution_id"
|
||||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||||
t.uuid "import_id"
|
t.uuid "import_id"
|
||||||
t.string "mode"
|
t.string "mode"
|
||||||
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
|
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
|
||||||
|
@ -418,6 +418,22 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_30_121302) do
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.string "email"
|
||||||
|
t.string "role"
|
||||||
|
t.string "token"
|
||||||
|
t.uuid "family_id", null: false
|
||||||
|
t.uuid "inviter_id", null: false
|
||||||
|
t.datetime "accepted_at"
|
||||||
|
t.datetime "expires_at"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["email"], name: "index_invitations_on_email"
|
||||||
|
t.index ["family_id"], name: "index_invitations_on_family_id"
|
||||||
|
t.index ["inviter_id"], name: "index_invitations_on_inviter_id"
|
||||||
|
t.index ["token"], name: "index_invitations_on_token", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "token", null: false
|
t.string "token", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
@ -605,6 +621,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_30_121302) do
|
||||||
add_foreign_key "import_rows", "imports"
|
add_foreign_key "import_rows", "imports"
|
||||||
add_foreign_key "imports", "families"
|
add_foreign_key "imports", "families"
|
||||||
add_foreign_key "institutions", "families"
|
add_foreign_key "institutions", "families"
|
||||||
|
add_foreign_key "invitations", "families"
|
||||||
|
add_foreign_key "invitations", "users", column: "inviter_id"
|
||||||
add_foreign_key "merchants", "families"
|
add_foreign_key "merchants", "families"
|
||||||
add_foreign_key "security_prices", "securities"
|
add_foreign_key "security_prices", "securities"
|
||||||
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
|
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
|
||||||
|
|
89
test/controllers/invitations_controller_test.rb
Normal file
89
test/controllers/invitations_controller_test.rb
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class InvitationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
sign_in @user = users(:family_admin)
|
||||||
|
@invitation = invitations(:one)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should get new" do
|
||||||
|
get new_invitation_url
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should create invitation for member" do
|
||||||
|
assert_difference("Invitation.count") do
|
||||||
|
assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do
|
||||||
|
post invitations_url, params: {
|
||||||
|
invitation: {
|
||||||
|
email: "new@example.com",
|
||||||
|
role: "member"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
invitation = Invitation.order(created_at: :desc).first
|
||||||
|
assert_equal "member", invitation.role
|
||||||
|
assert_equal @user, invitation.inviter
|
||||||
|
assert_equal "new@example.com", invitation.email
|
||||||
|
assert_redirected_to settings_profile_path
|
||||||
|
assert_equal I18n.t("invitations.create.success"), flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-admin cannot create invitations" do
|
||||||
|
sign_in users(:family_member)
|
||||||
|
|
||||||
|
assert_no_difference("Invitation.count") do
|
||||||
|
post invitations_url, params: {
|
||||||
|
invitation: {
|
||||||
|
email: "new@example.com",
|
||||||
|
role: "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to settings_profile_path
|
||||||
|
assert_equal I18n.t("invitations.create.failure"), flash[:alert]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin can create admin invitation" do
|
||||||
|
assert_difference("Invitation.count") do
|
||||||
|
post invitations_url, params: {
|
||||||
|
invitation: {
|
||||||
|
email: "new@example.com",
|
||||||
|
role: "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
invitation = Invitation.last
|
||||||
|
assert_equal "admin", invitation.role
|
||||||
|
assert_equal @user.family, invitation.family
|
||||||
|
assert_equal @user, invitation.inviter
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle invalid invitation creation" do
|
||||||
|
assert_no_difference("Invitation.count") do
|
||||||
|
post invitations_url, params: {
|
||||||
|
invitation: {
|
||||||
|
email: "",
|
||||||
|
role: "member"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to settings_profile_path
|
||||||
|
assert_equal I18n.t("invitations.create.failure"), flash[:alert]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should accept invitation and redirect to registration" do
|
||||||
|
get accept_invitation_url(@invitation.token)
|
||||||
|
assert_redirected_to new_registration_path(invitation: @invitation.token)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not accept invalid invitation token" do
|
||||||
|
get accept_invitation_url("invalid-token")
|
||||||
|
assert_response :not_found
|
||||||
|
end
|
||||||
|
end
|
19
test/fixtures/invitations.yml
vendored
Normal file
19
test/fixtures/invitations.yml
vendored
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
one:
|
||||||
|
email: "test@example.com"
|
||||||
|
token: "valid-token-123"
|
||||||
|
role: "member"
|
||||||
|
inviter: family_admin
|
||||||
|
family: dylan_family
|
||||||
|
created_at: <%= Time.current %>
|
||||||
|
updated_at: <%= Time.current %>
|
||||||
|
expires_at: <%= 3.days.from_now %>
|
||||||
|
|
||||||
|
two:
|
||||||
|
email: "another@example.com"
|
||||||
|
token: "valid-token-456"
|
||||||
|
role: "admin"
|
||||||
|
inviter: family_admin
|
||||||
|
family: dylan_family
|
||||||
|
created_at: <%= Time.current %>
|
||||||
|
updated_at: <%= Time.current %>
|
||||||
|
expires_at: <%= 3.days.from_now %>
|
Loading…
Add table
Add a link
Reference in a new issue