diff --git a/app/controllers/concerns/invitable.rb b/app/controllers/concerns/invitable.rb
index 1e0dfed85ef..5e12d2df994 100644
--- a/app/controllers/concerns/invitable.rb
+++ b/app/controllers/concerns/invitable.rb
@@ -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
diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb
new file mode 100644
index 00000000000..020b4707ca9
--- /dev/null
+++ b/app/controllers/invitations_controller.rb
@@ -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
diff --git a/app/controllers/onboardings_controller.rb b/app/controllers/onboardings_controller.rb
index 4fb5386f8f2..a98694712a8 100644
--- a/app/controllers/onboardings_controller.rb
+++ b/app/controllers/onboardings_controller.rb
@@ -1,7 +1,7 @@
class OnboardingsController < ApplicationController
layout "application"
-
before_action :set_user
+ before_action :load_invitation
def show
end
@@ -13,7 +13,12 @@ def preferences
end
private
+
def set_user
@user = Current.user
end
+
+ def load_invitation
+ @invitation = Invitation.accepted.most_recent_for_email(Current.user.email)
+ end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 8c55e58d03a..b5613d443f3 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -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
+
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
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 0caca54c196..882824ecb64 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -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
diff --git a/app/helpers/invitations_helper.rb b/app/helpers/invitations_helper.rb
new file mode 100644
index 00000000000..1483b9eeb7f
--- /dev/null
+++ b/app/helpers/invitations_helper.rb
@@ -0,0 +1,2 @@
+module InvitationsHelper
+end
diff --git a/app/mailers/invitation_mailer.rb b/app/mailers/invitation_mailer.rb
new file mode 100644
index 00000000000..d43a42fe60b
--- /dev/null
+++ b/app/mailers/invitation_mailer.rb
@@ -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
diff --git a/app/models/family.rb b/app/models/family.rb
index 80f392753d9..8d0d063bfbb 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -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
diff --git a/app/models/invitation.rb b/app/models/invitation.rb
new file mode 100644
index 00000000000..41770b50ac4
--- /dev/null
+++ b/app/models/invitation.rb
@@ -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
diff --git a/app/views/invitation_mailer/invite_email.html.erb b/app/views/invitation_mailer/invite_email.html.erb
new file mode 100644
index 00000000000..c57be0a9492
--- /dev/null
+++ b/app/views/invitation_mailer/invite_email.html.erb
@@ -0,0 +1,11 @@
+
<%= t(".greeting") %>
+
+
+ <%= t(".body",
+ inviter: @invitation.inviter.display_name,
+ family: @invitation.family.name).html_safe %>
+
+
+<%= link_to t(".accept_button"), @accept_url, class: "button" %>
+
+
\ No newline at end of file
diff --git a/app/views/invitations/new.html.erb b/app/views/invitations/new.html.erb
new file mode 100644
index 00000000000..5b6a455062e
--- /dev/null
+++ b/app/views/invitations/new.html.erb
@@ -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") } %>
+
+
+ <%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2 w-full" %>
+
+ <% end %>
+<% end %>
\ No newline at end of file
diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb
index 3aac9002edc..2d6f8c06d7e 100644
--- a/app/views/layouts/mailer.html.erb
+++ b/app/views/layouts/mailer.html.erb
@@ -2,12 +2,56 @@
+
- <%= yield %>
+
+ <%= yield %>
+
diff --git a/app/views/onboardings/profile.html.erb b/app/views/onboardings/profile.html.erb
index e2bda5b7567..0e0b99f5732 100644
--- a/app/views/onboardings/profile.html.erb
+++ b/app/views/onboardings/profile.html.erb
@@ -9,7 +9,8 @@
<%= 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 %>
<%= t(".profile_image") %>
@@ -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 %>
-
-
- <%= 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 %>
-
+ <% unless @invitation %>
+
+ <%= 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 %>
+
+ <% end %>
<%= form.submit t(".submit") %>
<% end %>
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb
index df7200fe211..6ad1f066e07 100644
--- a/app/views/registrations/new.html.erb
+++ b/app/views/registrations/new.html.erb
@@ -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 @@
<%= t(".welcome_title") %>
<%= t(".welcome_body") %>
+<% elsif @invitation %>
+
+
+ <%= t(".invitation_message",
+ inviter: @invitation.inviter.display_name,
+ role: t(".role_#{@invitation.role}")) %>
+
+
<% 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 %>
diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb
index c1c7cb8d098..dde691a2de4 100644
--- a/app/views/settings/profiles/show.html.erb
+++ b/app/views/settings/profiles/show.html.erb
@@ -34,15 +34,60 @@
<%= Current.family.name %> · <%= Current.family.users.size %>
-
-
-
<%= Current.user.initial %>
+ <% @users.each do |user| %>
+
+
+ <%= render "settings/user_avatar", user: user %>
+
+
<%= user.display_name %>
+
-
<%= Current.user.display_name %>
-
-
<%= Current.user.role %>
-
-
+ <% end %>
+ <% if @pending_invitations.any? %>
+ <% @pending_invitations.each do |invitation| %>
+
+
+
+
<%= invitation.email[0] %>
+
+
+
<%= invitation.email %>
+
+
+
+ <% if self_hosted? %>
+
+
<%= t(".invitation_link") %>
+
<%= accept_invitation_url(invitation.token) %>
+
+
+
+ <%= lucide_icon "copy", class: "w-5 h-5" %>
+
+
+ <%= lucide_icon "check", class: "w-5 h-5" %>
+
+
+
+ <% end %>
+
+ <% 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 %>
<% end %>
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index 78648c48283..e3134b174f6 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -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"
}
diff --git a/config/locales/mailers/invitation_mailer/en.yml b/config/locales/mailers/invitation_mailer/en.yml
new file mode 100644
index 00000000000..1bb494e83e9
--- /dev/null
+++ b/config/locales/mailers/invitation_mailer/en.yml
@@ -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."
diff --git a/config/locales/views/invitation_mailer/en.yml b/config/locales/views/invitation_mailer/en.yml
new file mode 100644
index 00000000000..650377213ff
--- /dev/null
+++ b/config/locales/views/invitation_mailer/en.yml
@@ -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"
diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml
new file mode 100644
index 00000000000..389cd35897f
--- /dev/null
+++ b/config/locales/views/invitations/en.yml
@@ -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
\ No newline at end of file
diff --git a/config/locales/views/registrations/en.yml b/config/locales/views/registrations/en.yml
index 3f2666ee914..5d487ee05f0 100644
--- a/config/locales/views/registrations/en.yml
+++ b/config/locales/views/registrations/en.yml
@@ -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!
diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml
index 0e2fe635bf2..4bb87e5c3ae 100644
--- a/config/locales/views/settings/en.yml
+++ b/config/locales/views/settings/en.yml
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index 70e5903292b..83c150b8084 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -111,6 +111,10 @@
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
diff --git a/db/migrate/20241030222235_create_invitations.rb b/db/migrate/20241030222235_create_invitations.rb
new file mode 100644
index 00000000000..7b7b2638730
--- /dev/null
+++ b/db/migrate/20241030222235_create_invitations.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 48a76165aa9..4db6b1457b2 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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 @@
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 @@
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 @@
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"
diff --git a/test/controllers/invitations_controller_test.rb b/test/controllers/invitations_controller_test.rb
new file mode 100644
index 00000000000..d6bdcacbe70
--- /dev/null
+++ b/test/controllers/invitations_controller_test.rb
@@ -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
diff --git a/test/fixtures/invitations.yml b/test/fixtures/invitations.yml
new file mode 100644
index 00000000000..12ae9a052e8
--- /dev/null
+++ b/test/fixtures/invitations.yml
@@ -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 %>