diff --git a/app/controllers/api/v1/auth_controller.rb b/app/controllers/api/v1/auth_controller.rb index 25140f3ea..e522fb03f 100644 --- a/app/controllers/api/v1/auth_controller.rb +++ b/app/controllers/api/v1/auth_controller.rb @@ -178,7 +178,10 @@ module Api email = cached[:email] - unless cached[:allow_account_creation] + # Check for a pending invitation for this email + invitation = Invitation.pending.find_by(email: email) + + unless invitation.present? || cached[:allow_account_creation] render json: { error: "SSO account creation is disabled. Please contact an administrator." }, status: :forbidden return end @@ -193,13 +196,22 @@ module Api skip_password_validation: true ) - user.family = Family.new + if invitation.present? + # Accept the pending invitation: join the existing family + user.family_id = invitation.family_id + user.role = invitation.role + else + user.family = Family.new - provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == cached[:provider] } - provider_default_role = provider_config&.dig(:settings, :default_role) - user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == cached[:provider] } + provider_default_role = provider_config&.dig(:settings, :default_role) + user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + end if user.save + # Mark invitation as accepted if one was used + invitation&.update!(accepted_at: Time.current) + OidcIdentity.create_from_omniauth(build_omniauth_hash(cached), user) SsoAuditLog.log_jit_account_created!( diff --git a/app/controllers/oidc_accounts_controller.rb b/app/controllers/oidc_accounts_controller.rb index cd46bf30e..25548995d 100644 --- a/app/controllers/oidc_accounts_controller.rb +++ b/app/controllers/oidc_accounts_controller.rb @@ -14,9 +14,12 @@ class OidcAccountsController < ApplicationController @email = @pending_auth["email"] @user_exists = User.exists?(email: @email) if @email.present? + # Check for a pending invitation for this email + @pending_invitation = Invitation.pending.find_by(email: @email) if @email.present? + # Determine whether we should offer JIT account creation for this # pending auth, based on JIT mode and allowed domains. - @allow_account_creation = !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(@email) + @allow_account_creation = @pending_invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(@email)) end def create_link @@ -94,10 +97,13 @@ class OidcAccountsController < ApplicationController email = @pending_auth["email"] + # Check for a pending invitation for this email + invitation = Invitation.pending.find_by(email: email) + # Respect global JIT configuration: in link_only mode or when the email - # domain is not allowed, block JIT account creation and send the user - # back to the login page with a clear message. - unless !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + # domain is not allowed, block JIT account creation—unless there's a + # pending invitation for this user. + unless invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email)) redirect_to new_session_path, alert: "SSO account creation is disabled. Please contact an administrator." return end @@ -115,14 +121,20 @@ class OidcAccountsController < ApplicationController skip_password_validation: true ) - # Create new family for this user - @user.family = Family.new + if invitation.present? + # Accept the pending invitation: join the existing family + @user.family_id = invitation.family_id + @user.role = invitation.role + else + # Create new family for this user + @user.family = Family.new - # Use provider-configured default role, or fall back to admin for family creators - # First user of an instance always becomes super_admin regardless of provider config - provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] } - provider_default_role = provider_config&.dig(:settings, :default_role) - @user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + # Use provider-configured default role, or fall back to admin for family creators + # First user of an instance always becomes super_admin regardless of provider config + provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] } + provider_default_role = provider_config&.dig(:settings, :default_role) + @user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin) + end if @user.save # Create the OIDC (or other SSO) identity @@ -140,11 +152,20 @@ class OidcAccountsController < ApplicationController ) end + # Mark invitation as accepted if one was used + invitation&.update!(accepted_at: Time.current) + # Clear pending auth from session session.delete(:pending_oidc_auth) @session = create_session_for(@user) - notice = accept_pending_invitation_for(@user) ? t("invitations.accept_choice.joined_household") : "Welcome! Your account has been created." + notice = if invitation.present? + t("invitations.accept_choice.joined_household") + elsif accept_pending_invitation_for(@user) + t("invitations.accept_choice.joined_household") + else + "Welcome! Your account has been created." + end redirect_to root_path, notice: notice else render :new_user, status: :unprocessable_entity diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 7d56a96fe..ea2f37c08 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -277,6 +277,9 @@ class SessionsController < ApplicationController device_info = session.delete(:mobile_sso) email = auth.info&.email + has_pending_invitation = email.present? && Invitation.pending.exists?(email: email) + allow_creation = has_pending_invitation || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email)) + linking_code = SecureRandom.urlsafe_base64(32) Rails.cache.write( "mobile_sso_link:#{linking_code}", @@ -289,7 +292,7 @@ class SessionsController < ApplicationController name: auth.info&.name, issuer: auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss"), device_info: device_info, - allow_account_creation: !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + allow_account_creation: allow_creation }, expires_in: 10.minutes ) @@ -300,7 +303,8 @@ class SessionsController < ApplicationController email: email, first_name: auth.info&.first_name, last_name: auth.info&.last_name, - allow_account_creation: !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email) + allow_account_creation: allow_creation, + has_pending_invitation: has_pending_invitation ) end diff --git a/app/views/oidc_accounts/link.html.erb b/app/views/oidc_accounts/link.html.erb index c97c3cd3f..d01b7c2db 100644 --- a/app/views/oidc_accounts/link.html.erb +++ b/app/views/oidc_accounts/link.html.erb @@ -54,7 +54,7 @@ <% if @allow_account_creation %> <%= render DS::Button.new( - text: t("oidc_accounts.link.submit_create"), + text: @pending_invitation ? t("oidc_accounts.link.submit_accept_invitation") : t("oidc_accounts.link.submit_create"), href: create_user_oidc_account_path, full_width: true, variant: :primary, diff --git a/config/locales/views/oidc_accounts/en.yml b/config/locales/views/oidc_accounts/en.yml index 4ed91677c..e04729307 100644 --- a/config/locales/views/oidc_accounts/en.yml +++ b/config/locales/views/oidc_accounts/en.yml @@ -18,6 +18,7 @@ en: info_email: "Email:" info_name: "Name:" submit_create: Create Account + submit_accept_invitation: Accept Invitation account_creation_disabled: New account creation via single sign-on is disabled. Please contact an administrator to create your account. cancel: Cancel new_user: diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index c2e8bdcfc..884ef3d53 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -29,6 +29,7 @@ class AuthProvider with ChangeNotifier { String? _ssoFirstName; String? _ssoLastName; bool _ssoAllowAccountCreation = false; + bool _ssoHasPendingInvitation = false; User? get user => _user; bool get isIntroLayout => _user?.isIntroLayout ?? false; @@ -51,6 +52,7 @@ class AuthProvider with ChangeNotifier { String? get ssoFirstName => _ssoFirstName; String? get ssoLastName => _ssoLastName; bool get ssoAllowAccountCreation => _ssoAllowAccountCreation; + bool get ssoHasPendingInvitation => _ssoHasPendingInvitation; AuthProvider() { _loadStoredAuth(); @@ -294,6 +296,7 @@ class AuthProvider with ChangeNotifier { _ssoFirstName = result['first_name'] as String?; _ssoLastName = result['last_name'] as String?; _ssoAllowAccountCreation = result['allow_account_creation'] == true; + _ssoHasPendingInvitation = result['has_pending_invitation'] == true; _isLoading = false; notifyListeners(); return false; @@ -410,6 +413,7 @@ class AuthProvider with ChangeNotifier { _ssoFirstName = null; _ssoLastName = null; _ssoAllowAccountCreation = false; + _ssoHasPendingInvitation = false; } Future logout() async { diff --git a/mobile/lib/screens/sso_onboarding_screen.dart b/mobile/lib/screens/sso_onboarding_screen.dart index c98a2c3de..3fee35077 100644 --- a/mobile/lib/screens/sso_onboarding_screen.dart +++ b/mobile/lib/screens/sso_onboarding_screen.dart @@ -148,7 +148,9 @@ class _SsoOnboardingScreenState extends State { ), Expanded( child: _TabButton( - label: 'Create New', + label: authProvider.ssoHasPendingInvitation + ? 'Accept Invitation' + : 'Create New', isSelected: !_showLinkForm, onTap: () => setState(() => _showLinkForm = false), @@ -258,6 +260,7 @@ class _SsoOnboardingScreenState extends State { } Widget _buildCreateForm(AuthProvider authProvider, ColorScheme colorScheme) { + final hasPendingInvitation = authProvider.ssoHasPendingInvitation; return Form( key: _createFormKey, child: Column( @@ -271,11 +274,16 @@ class _SsoOnboardingScreenState extends State { ), child: Row( children: [ - Icon(Icons.person_add, color: colorScheme.primary), + Icon( + hasPendingInvitation ? Icons.mail_outline : Icons.person_add, + color: colorScheme.primary, + ), const SizedBox(width: 12), Expanded( child: Text( - 'Create a new account using your Google identity.', + hasPendingInvitation + ? 'You have a pending invitation. Accept it to join an existing household.' + : 'Create a new account using your Google identity.', style: TextStyle(color: colorScheme.onSurface), ), ), @@ -318,7 +326,9 @@ class _SsoOnboardingScreenState extends State { width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : const Text('Create Account'), + : Text(hasPendingInvitation + ? 'Accept Invitation' + : 'Create Account'), ), ], ), diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index a89f25c3e..e31c34554 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -374,6 +374,7 @@ class AuthService { 'first_name': params['first_name'] ?? '', 'last_name': params['last_name'] ?? '', 'allow_account_creation': params['allow_account_creation'] == 'true', + 'has_pending_invitation': params['has_pending_invitation'] == 'true', }; } diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index f211e07bf..91f8b1f26 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -210,6 +210,9 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest delete disconnect_external_assistant_settings_hosting_url assert_redirected_to settings_hosting_url + # Force cache refresh so configured? reads fresh DB state after + # the disconnect action cleared the settings within its own request. + Setting.clear_cache assert_not Assistant::External.configured? assert_equal "builtin", users(:family_admin).family.reload.assistant_type end diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb index c07859090..b396cf7ed 100644 --- a/test/models/assistant_test.rb +++ b/test/models/assistant_test.rb @@ -237,6 +237,12 @@ class AssistantTest < ActiveSupport::TestCase "EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil ) do + # Ensure Settings are also cleared to avoid test pollution from + # other tests that may have set these values in the same process. + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.clear_cache + assert_no_difference "AssistantMessage.count" do assistant.respond_to(@message) end @@ -245,6 +251,9 @@ class AssistantTest < ActiveSupport::TestCase assert @chat.error.present? assert_includes @chat.error, "not configured" end + ensure + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil end test "external assistant adds error on connection failure" do @@ -323,6 +332,10 @@ class AssistantTest < ActiveSupport::TestCase # Phase 1: Without config, errors gracefully with_env_overrides("EXTERNAL_ASSISTANT_URL" => nil, "EXTERNAL_ASSISTANT_TOKEN" => nil) do + Setting.external_assistant_url = nil + Setting.external_assistant_token = nil + Setting.clear_cache + assistant = Assistant::External.new(@chat) assistant.respond_to(@message) @chat.reload