From fa3d926a9266edf18f7804544e5acb12fbbc8b4d Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 28 Sep 2025 20:53:50 +0200 Subject: [PATCH] Change registration flow to support family invitations and self-hosted mode restrictions. --- .../family_invitations_controller.rb | 24 +- .../users/registrations_controller.rb | 99 ++++++ app/views/devise/registrations/new.html.erb | 55 ++- config/routes.rb | 12 +- spec/requests/users/registrations_spec.rb | 323 ++++++++++++++++++ 5 files changed, 479 insertions(+), 34 deletions(-) create mode 100644 app/controllers/users/registrations_controller.rb create mode 100644 spec/requests/users/registrations_spec.rb 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 + +
+
+ <% else %> +

Register now!

+

and take control over your location data.

+ <% end %>
-
+
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %> + <% if @invitation %> + <%= f.hidden_field :invitation_token, value: params[:invitation_token] %> + <% end %> +
<%= f.label :email, class: 'label' do %> - Email + Email <% end %> - <%= f.email_field :email, autofocus: true, autocomplete: "email", class: 'input input-bordered' %> + <%= f.email_field :email, autofocus: true, autocomplete: "email", + readonly: @invitation.present?, + class: "input input-bordered bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600 #{@invitation ? 'bg-gray-50 dark:bg-gray-600' : ''}" %>
<%= f.label :password, class: 'label' do %> - Password + Password <% end %> <% if @minimum_password_length %> - (<%= @minimum_password_length %> characters minimum) + (<%= @minimum_password_length %> characters minimum) <% end %>
- <%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered' %> + <%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600' %>
<%= f.label :password_confirmation, class: 'label' do %> - Password + Password Confirmation <% end %> <% if @minimum_password_length %> - (<%= @minimum_password_length %> characters minimum) + (<%= @minimum_password_length %> characters minimum) <% end %>
- <%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered' %> + <%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border-gray-300 dark:border-gray-600' %>
<% if !DawarichSettings.self_hosted? %> @@ -38,10 +62,13 @@ <% end %>
- <%= f.submit "Sign up", class: 'btn btn-primary' %> + <%= f.submit (@invitation ? "Create Account & Join Family" : "Sign up"), + class: 'btn btn-primary bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white border-none' %>
- <%= render "devise/shared/links" %> + <% unless @invitation %> + <%= render "devise/shared/links" %> + <% end %> <% end %>
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