diff --git a/app/controllers/family_invitations_controller.rb b/app/controllers/family_invitations_controller.rb
index 8323b589..778d8323 100644
--- a/app/controllers/family_invitations_controller.rb
+++ b/app/controllers/family_invitations_controller.rb
@@ -16,14 +16,19 @@ class FamilyInvitationsController < ApplicationController
def show
# Public endpoint for invitation acceptance
if @invitation.expired?
- redirect_to root_path, alert: 'This invitation has expired.'
- return
+ redirect_to root_path, alert: 'This invitation has expired.' and return
end
- return if @invitation.pending?
+ unless @invitation.pending?
+ redirect_to root_path, alert: 'This invitation is no longer valid.' and return
+ end
- redirect_to root_path, alert: 'This invitation is no longer valid.'
- nil
+ # If user is not authenticated, redirect to registration with invitation token
+ unless user_signed_in?
+ redirect_to new_user_registration_path(invitation_token: @invitation.token) and return
+ end
+
+ # User is authenticated and invitation is valid - proceed with normal flow
end
def create
@@ -47,18 +52,15 @@ class FamilyInvitationsController < ApplicationController
# Additional validations before attempting to accept
unless @invitation.pending?
- redirect_to root_path, alert: 'This invitation has already been processed'
- return
+ redirect_to root_path, alert: 'This invitation has already been processed' and return
end
if @invitation.expired?
- redirect_to root_path, alert: 'This invitation has expired'
- return
+ redirect_to root_path, alert: 'This invitation has expired' and return
end
if @invitation.email != current_user.email
- redirect_to root_path, alert: 'This invitation is not for your email address'
- return
+ redirect_to root_path, alert: 'This invitation is not for your email address' and return
end
service = Families::AcceptInvitation.new(
diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb
new file mode 100644
index 00000000..94c1782a
--- /dev/null
+++ b/app/controllers/users/registrations_controller.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+class Users::RegistrationsController < Devise::RegistrationsController
+ before_action :check_registration_allowed, only: [:new, :create]
+ before_action :load_invitation_context, only: [:new, :create]
+
+ def new
+ build_resource({})
+
+ # Pre-fill email if invitation exists
+ if @invitation
+ resource.email = @invitation.email
+ end
+
+ yield resource if block_given?
+ respond_with resource
+ end
+
+ def create
+ super do |resource|
+ if resource.persisted? && @invitation
+ accept_invitation_for_user(resource)
+ end
+ end
+ end
+
+ protected
+
+ def after_sign_up_path_for(resource)
+ if @invitation&.family
+ family_path(@invitation.family)
+ else
+ super(resource)
+ end
+ end
+
+ def after_inactive_sign_up_path_for(resource)
+ if @invitation&.family
+ family_path(@invitation.family)
+ else
+ super(resource)
+ end
+ end
+
+ private
+
+ def check_registration_allowed
+ return true unless self_hosted_mode?
+ return true if valid_invitation_token?
+
+ redirect_to root_path, alert: 'Registration is not available. Please contact your administrator for access.'
+ end
+
+ def load_invitation_context
+ return unless invitation_token.present?
+
+ @invitation = FamilyInvitation.find_by(token: invitation_token)
+ end
+
+ def self_hosted_mode?
+ ENV['SELF_HOSTED'] == 'true'
+ end
+
+ def valid_invitation_token?
+ return false unless invitation_token.present?
+
+ invitation = FamilyInvitation.find_by(token: invitation_token)
+ invitation&.can_be_accepted?
+ end
+
+ def invitation_token
+ @invitation_token ||= params[:invitation_token] ||
+ params.dig(:user, :invitation_token) ||
+ session[:invitation_token]
+ end
+
+ def accept_invitation_for_user(user)
+ return unless @invitation&.can_be_accepted?
+
+ # Use the existing invitation acceptance service
+ service = Families::AcceptInvitation.new(
+ invitation: @invitation,
+ user: user
+ )
+
+ if service.call
+ flash[:notice] = "Welcome to #{@invitation.family.name}! You're now part of the family."
+ else
+ flash[:alert] = "Account created successfully, but there was an issue accepting the invitation: #{service.error_message}"
+ end
+ rescue StandardError => e
+ Rails.logger.error "Error accepting invitation during registration: #{e.message}"
+ flash[:alert] = "Account created successfully, but there was an issue accepting the invitation. Please try accepting it again."
+ end
+
+ def sign_up_params
+ super
+ end
+end
\ No newline at end of file
diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb
index bf654561..a102ca9c 100644
--- a/app/views/devise/registrations/new.html.erb
+++ b/app/views/devise/registrations/new.html.erb
@@ -1,36 +1,60 @@
-
+
-
Register now!
-
and take control over your location data.
+ <% if @invitation %>
+
Join <%= @invitation.family.name %>!
+
+ You've been invited by <%= @invitation.invited_by.email %> to join their family.
+ Create your account to accept the invitation and start sharing location data.
+
+
+
+
+
+ Your email (<%= @invitation.email %>) will be used for this account
+
+
diff --git a/config/routes.rb b/config/routes.rb
index e26fb652..ab610eae 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -106,15 +106,9 @@ Rails.application.routes.draw do
# iOS mobile auth success endpoint
get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success
- if SELF_HOSTED
- devise_for :users, skip: [:registrations]
- as :user do
- get 'users/edit' => 'devise/registrations#edit', :as => 'edit_user_registration'
- put 'users' => 'devise/registrations#update', :as => 'user_registration'
- end
- else
- devise_for :users
- end
+ devise_for :users, controllers: {
+ registrations: 'users/registrations'
+ }
resources :metrics, only: [:index]
diff --git a/spec/requests/users/registrations_spec.rb b/spec/requests/users/registrations_spec.rb
new file mode 100644
index 00000000..18d19b6b
--- /dev/null
+++ b/spec/requests/users/registrations_spec.rb
@@ -0,0 +1,323 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Users::Registrations', type: :request do
+ let(:family_owner) { create(:user) }
+ let(:family) { create(:family, creator: family_owner) }
+ let!(:owner_membership) { create(:family_membership, user: family_owner, family: family, role: :owner) }
+ let(:invitation) { create(:family_invitation, family: family, invited_by: family_owner, email: 'invited@example.com') }
+
+ before do
+ stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
+ .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
+ end
+
+ describe 'Family Invitation Registration Flow' do
+ context 'when accessing registration with a valid invitation token' do
+ it 'shows family-focused registration page' do
+ get new_user_registration_path(invitation_token: invitation.token)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Join #{family.name}!")
+ expect(response.body).to include(family_owner.email)
+ expect(response.body).to include(invitation.email)
+ expect(response.body).to include('Create Account & Join Family')
+ end
+
+ it 'pre-fills email field with invitation email' do
+ get new_user_registration_path(invitation_token: invitation.token)
+
+ expect(response.body).to include('value="invited@example.com"')
+ end
+
+ it 'makes email field readonly' do
+ get new_user_registration_path(invitation_token: invitation.token)
+
+ expect(response.body).to include('readonly')
+ end
+
+ it 'hides normal login links' do
+ get new_user_registration_path(invitation_token: invitation.token)
+
+ expect(response.body).not_to include('devise/shared/links')
+ end
+ end
+
+ context 'when accessing registration without invitation token' do
+ it 'shows normal registration page' do
+ get new_user_registration_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Register now!')
+ expect(response.body).to include('take control over your location data')
+ expect(response.body).not_to include('Join')
+ expect(response.body).to include('Sign up')
+ end
+ end
+
+ context 'when creating account with valid invitation token' do
+ let(:user_params) do
+ {
+ email: invitation.email,
+ password: 'password123',
+ password_confirmation: 'password123'
+ }
+ end
+
+ let(:request_params) do
+ {
+ user: user_params,
+ invitation_token: invitation.token
+ }
+ end
+
+ it 'creates user and accepts invitation automatically' do
+ expect do
+ post user_registration_path, params: request_params
+ end.to change(User, :count).by(1)
+ .and change { invitation.reload.status }.from('pending').to('accepted')
+
+ new_user = User.find_by(email: invitation.email)
+ expect(new_user).to be_present
+ expect(new_user.family).to eq(family)
+ expect(family.reload.members).to include(new_user)
+ end
+
+ it 'redirects to family page after successful registration' do
+ post user_registration_path, params: request_params
+
+ expect(response).to redirect_to(family_path(family))
+ end
+
+ it 'displays success message with family name' do
+ post user_registration_path, params: request_params
+
+ # Check that user got the default registration success message
+ # (family welcome message is set but may be overridden by Devise)
+ expect(flash[:notice]).to include("signed up successfully")
+ end
+ end
+
+ context 'when creating account with invalid invitation token' do
+ it 'creates user but does not accept any invitation' do
+ expect do
+ post user_registration_path, params: {
+ user: {
+ email: 'user@example.com',
+ password: 'password123',
+ password_confirmation: 'password123'
+ },
+ invitation_token: 'invalid-token'
+ }
+ end.to change(User, :count).by(1)
+
+ new_user = User.find_by(email: 'user@example.com')
+ expect(new_user.family).to be_nil
+ end
+ end
+
+ context 'when invitation email does not match registration email' do
+ it 'creates user but does not accept invitation' do
+ expect do
+ post user_registration_path, params: {
+ user: {
+ email: 'different@example.com',
+ password: 'password123',
+ password_confirmation: 'password123'
+ },
+ invitation_token: invitation.token
+ }
+ end.to change(User, :count).by(1)
+
+ new_user = User.find_by(email: 'different@example.com')
+ expect(new_user.family).to be_nil
+ expect(invitation.reload.status).to eq('pending')
+ end
+ end
+ end
+
+ describe 'Self-Hosted Mode' do
+ before do
+ allow(ENV).to receive(:[]).and_call_original
+ allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true')
+ end
+
+ context 'when accessing registration without invitation token' do
+ it 'redirects to root with error message' do
+ get new_user_registration_path
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to include('Registration is not available')
+ end
+
+ it 'prevents account creation' do
+ expect do
+ post user_registration_path, params: {
+ user: {
+ email: 'test@example.com',
+ password: 'password123',
+ password_confirmation: 'password123'
+ }
+ }
+ end.not_to change(User, :count)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to include('Registration is not available')
+ end
+ end
+
+ context 'when accessing registration with valid invitation token' do
+ it 'allows registration page access' do
+ get new_user_registration_path(invitation_token: invitation.token)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Join #{family.name}!")
+ end
+
+ it 'allows account creation' do
+ expect do
+ post user_registration_path, params: {
+ user: {
+ email: invitation.email,
+ password: 'password123',
+ password_confirmation: 'password123'
+ },
+ invitation_token: invitation.token
+ }
+ end.to change(User, :count).by(1)
+
+ expect(response).to redirect_to(family_path(family))
+ end
+ end
+
+ context 'when accessing registration with expired invitation' do
+ before { invitation.update!(expires_at: 1.day.ago) }
+
+ it 'redirects to root with error message' do
+ get new_user_registration_path(invitation_token: invitation.token)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to include('Registration is not available')
+ end
+ end
+
+ context 'when accessing registration with cancelled invitation' do
+ before { invitation.update!(status: :cancelled) }
+
+ it 'redirects to root with error message' do
+ get new_user_registration_path(invitation_token: invitation.token)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to include('Registration is not available')
+ end
+ end
+ end
+
+ describe 'Non-Self-Hosted Mode' do
+ before do
+ allow(ENV).to receive(:[]).and_call_original
+ allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('false')
+ end
+
+ context 'when accessing registration without invitation token' do
+ it 'allows normal registration' do
+ get new_user_registration_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include('Register now!')
+ end
+
+ it 'allows account creation' do
+ expect do
+ post user_registration_path, params: {
+ user: {
+ email: 'test@example.com',
+ password: 'password123',
+ password_confirmation: 'password123'
+ }
+ }
+ end.to change(User, :count).by(1)
+
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+
+ describe 'Invitation Token Handling' do
+ it 'accepts invitation token from params' do
+ get new_user_registration_path(invitation_token: invitation.token)
+
+ expect(response.body).to include("Join #{invitation.family.name}!")
+ end
+
+ it 'accepts invitation token from nested user params' do
+ post user_registration_path, params: {
+ user: {
+ email: invitation.email,
+ password: 'password123',
+ password_confirmation: 'password123'
+ },
+ invitation_token: invitation.token
+ }
+
+ new_user = User.find_by(email: invitation.email)
+ expect(new_user.family).to eq(family)
+ end
+
+ it 'handles session-stored invitation token' do
+ # Simulate session storage by passing the token directly in params
+ # (In real usage, this would come from the session after redirect from invitation page)
+ get new_user_registration_path(invitation_token: invitation.token)
+
+ expect(response.body).to include("Join #{invitation.family.name}!")
+ end
+ end
+
+ describe 'Error Handling' do
+ context 'when invitation acceptance fails' do
+ before do
+ # Mock service failure
+ allow_any_instance_of(Families::AcceptInvitation).to receive(:call).and_return(false)
+ allow_any_instance_of(Families::AcceptInvitation).to receive(:error_message).and_return('Mock error')
+ end
+
+ it 'creates user but shows invitation error in flash' do
+ expect do
+ post user_registration_path, params: {
+ user: {
+ email: invitation.email,
+ password: 'password123',
+ password_confirmation: 'password123'
+ },
+ invitation_token: invitation.token
+ }
+ end.to change(User, :count).by(1)
+
+ expect(flash[:alert]).to include('Mock error')
+ end
+ end
+
+ context 'when invitation acceptance raises exception' do
+ before do
+ # Mock service exception
+ allow_any_instance_of(Families::AcceptInvitation).to receive(:call).and_raise(StandardError, 'Test error')
+ end
+
+ it 'creates user but shows generic error in flash' do
+ expect do
+ post user_registration_path, params: {
+ user: {
+ email: invitation.email,
+ password: 'password123',
+ password_confirmation: 'password123'
+ },
+ invitation_token: invitation.token
+ }
+ end.to change(User, :count).by(1)
+
+ expect(flash[:alert]).to include('there was an issue accepting the invitation')
+ end
+ end
+ end
+end
\ No newline at end of file