1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +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:
Josh Pigford 2024-11-01 10:23:27 -05:00 committed by GitHub
parent 09b269273a
commit 793bd852a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 502 additions and 45 deletions

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,2 @@
module InvitationsHelper
end

View 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

View file

@ -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
View 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

View 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>

View 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 %>

View file

@ -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>

View file

@ -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 %>

View file

@ -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 %>

View file

@ -34,15 +34,60 @@
<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 @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 %>

View file

@ -38,7 +38,7 @@
"type": "controller",
"class": "AccountsController",
"method": "show",
"line": 39,
"line": 36,
"file": "app/controllers/accounts_controller.rb",
"rendered": {
"name": "accounts/show",
@ -72,7 +72,7 @@
"type": "controller",
"class": "AccountsController",
"method": "show",
"line": 39,
"line": 36,
"file": "app/controllers/accounts_controller.rb",
"rendered": {
"name": "accounts/show",
@ -91,6 +91,29 @@
],
"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_code": 2,
@ -140,7 +163,7 @@
"type": "controller",
"class": "AccountsController",
"method": "show",
"line": 39,
"line": 36,
"file": "app/controllers/accounts_controller.rb",
"rendered": {
"name": "accounts/show",
@ -194,6 +217,6 @@
"note": ""
}
],
"updated": "2024-10-17 11:30:15 -0400",
"brakeman_version": "6.2.1"
"updated": "2024-11-01 09:36:40 -0500",
"brakeman_version": "6.2.2"
}

View 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."

View 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"

View 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

View file

@ -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!

View file

@ -41,6 +41,7 @@ en:
theme_title: Theme
profiles:
show:
invite_member: "Add member"
confirm_delete:
body: Are you sure you want to permanently delete your account? This action
is irreversible.
@ -60,6 +61,8 @@ en:
profile_subtitle: Customize how you appear on Maybe
profile_title: Profile
save: Save
pending: Pending
invitation_link: Invitation link
user_avatar_field:
accepted_formats: JPG or PNG. 5MB max.
choose: Choose

View file

@ -111,6 +111,10 @@ Rails.application.routes.draw do
resources :exchange_rate_provider_missings, only: :update
end
resources :invitations, only: [ :new, :create ] do
get :accept, on: :member
end
# For managing self-hosted upgrades and release notifications
resources :upgrades, only: [] do
member do

View 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
View file

@ -10,7 +10,7 @@
#
# 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
enable_extension "pgcrypto"
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.date "last_sync_date"
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.string "mode"
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
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|
t.string "token", 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 "imports", "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 "security_prices", "securities"
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"

View 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
View 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 %>