propel_authentication 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -2
  3. data/README.md +6 -6
  4. data/lib/generators/propel_authentication/install_generator.rb +135 -153
  5. data/lib/generators/propel_authentication/templates/application_mailer.rb +6 -0
  6. data/lib/generators/propel_authentication/templates/auth/passwords_controller.rb.tt +84 -78
  7. data/lib/generators/propel_authentication/templates/auth/signup_controller.rb.tt +242 -0
  8. data/lib/generators/propel_authentication/templates/{tokens_controller.rb.tt → auth/tokens_controller.rb.tt} +39 -22
  9. data/lib/generators/propel_authentication/templates/auth_mailer.rb +3 -1
  10. data/lib/generators/propel_authentication/templates/authenticatable.rb +8 -2
  11. data/lib/generators/propel_authentication/templates/concerns/confirmable.rb +1 -1
  12. data/lib/generators/propel_authentication/templates/concerns/lockable.rb +4 -2
  13. data/lib/generators/propel_authentication/templates/concerns/{propel_authentication.rb → propel_authentication_concern.rb} +33 -3
  14. data/lib/generators/propel_authentication/templates/concerns/recoverable.rb +16 -6
  15. data/lib/generators/propel_authentication/templates/core/configuration_methods.rb +104 -64
  16. data/lib/generators/propel_authentication/templates/db/seeds.rb +50 -4
  17. data/lib/generators/propel_authentication/templates/doc/signup_flow.md +315 -0
  18. data/lib/generators/propel_authentication/templates/models/agency.rb.tt +13 -0
  19. data/lib/generators/propel_authentication/templates/models/agent.rb.tt +13 -0
  20. data/lib/generators/propel_authentication/templates/{invitation.rb → models/invitation.rb.tt} +6 -0
  21. data/lib/generators/propel_authentication/templates/models/organization.rb.tt +12 -0
  22. data/lib/generators/propel_authentication/templates/{user.rb → models/user.rb.tt} +5 -0
  23. data/lib/generators/propel_authentication/templates/propel_authentication.rb.tt +94 -9
  24. data/lib/generators/propel_authentication/templates/routes/auth_routes.rb.tt +55 -0
  25. data/lib/generators/propel_authentication/templates/services/auth_notification_service.rb +3 -3
  26. data/lib/generators/propel_authentication/templates/test/concerns/confirmable_test.rb.tt +34 -10
  27. data/lib/generators/propel_authentication/templates/test/concerns/propel_authentication_test.rb.tt +1 -1
  28. data/lib/generators/propel_authentication/templates/test/concerns/recoverable_test.rb.tt +4 -4
  29. data/lib/generators/propel_authentication/templates/test/controllers/auth/lockable_integration_test.rb.tt +18 -15
  30. data/lib/generators/propel_authentication/templates/test/controllers/auth/password_reset_integration_test.rb.tt +38 -40
  31. data/lib/generators/propel_authentication/templates/test/controllers/auth/signup_controller_test.rb.tt +201 -0
  32. data/lib/generators/propel_authentication/templates/test/controllers/auth/tokens_controller_test.rb.tt +33 -25
  33. data/lib/generators/propel_authentication/templates/test/mailers/auth_mailer_test.rb.tt +51 -36
  34. data/lib/generators/propel_authentication/templates/views/auth_mailer/email_confirmation.html.erb +2 -2
  35. data/lib/generators/propel_authentication/templates/views/auth_mailer/email_confirmation.text.erb +1 -1
  36. data/lib/generators/propel_authentication/test/generators/authentication/install_generator_test.rb +4 -4
  37. data/lib/generators/propel_authentication/test/generators/authentication/uninstall_generator_test.rb +1 -1
  38. data/lib/generators/propel_authentication/test/integration/generator_integration_test.rb +1 -1
  39. data/lib/generators/propel_authentication/test/integration/multi_version_generator_test.rb +13 -12
  40. data/lib/generators/propel_authentication/unpack_generator.rb +19 -15
  41. data/lib/propel_authentication.rb +1 -1
  42. metadata +14 -11
  43. data/lib/generators/propel_authentication/templates/agency.rb +0 -7
  44. data/lib/generators/propel_authentication/templates/agent.rb +0 -7
  45. data/lib/generators/propel_authentication/templates/auth/base_passwords_controller.rb.tt +0 -99
  46. data/lib/generators/propel_authentication/templates/auth/base_tokens_controller.rb.tt +0 -90
  47. data/lib/generators/propel_authentication/templates/organization.rb +0 -7
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module PropelAuthentication
3
+ module PropelAuthenticationConcern
4
4
  extend ActiveSupport::Concern
5
5
 
6
- private
7
-
8
6
  def authenticate_user
9
7
  token = extract_jwt_token
10
8
 
@@ -19,6 +17,10 @@ module PropelAuthentication
19
17
  render json: { error: 'Invalid token' }, status: :unauthorized
20
18
  return false
21
19
  end
20
+
21
+ # Extract organization context from JWT payload
22
+ extract_organization_context(token)
23
+
22
24
  rescue JWT::ExpiredSignature
23
25
  render json: { error: 'Token expired' }, status: :unauthorized
24
26
  return false
@@ -34,6 +36,25 @@ module PropelAuthentication
34
36
  @current_user
35
37
  end
36
38
 
39
+ def current_organization_id
40
+ @current_organization_id
41
+ end
42
+
43
+ def current_organization
44
+ @current_organization ||= Organization.find_by(id: current_organization_id) if current_organization_id
45
+ end
46
+
47
+ def current_agency_ids
48
+ return [] unless current_user
49
+
50
+ # Request-scoped memoization for performance without security risk
51
+ @current_agency_ids ||= current_user.agency_ids
52
+ end
53
+
54
+ def has_agency_access?(agency_id)
55
+ current_agency_ids.include?(agency_id.to_i)
56
+ end
57
+
37
58
  def extract_jwt_token
38
59
  auth_header = request.headers['Authorization']
39
60
  return nil unless auth_header
@@ -41,4 +62,13 @@ module PropelAuthentication
41
62
  # Extract token from "Bearer <token>" format
42
63
  auth_header.split(' ').last if auth_header.start_with?('Bearer ')
43
64
  end
65
+
66
+ private
67
+
68
+ def extract_organization_context(token)
69
+ decoded_token = JWT.decode(token, PropelAuthentication.configuration.jwt_secret, true, { algorithm: 'HS256' })
70
+ payload = decoded_token.first
71
+ @current_organization_id = payload['organization_id']
72
+ # Removed: @current_agency_ids (now looked up in real-time via current_user_agency_ids)
73
+ end
44
74
  end
@@ -51,12 +51,22 @@ module Recoverable
51
51
  # Reset password with token validation
52
52
  def reset_password_with_token!(token, new_password, new_password_confirmation = new_password)
53
53
  return false unless valid_password_reset_token?(token)
54
- return false if new_password.blank?
55
- return false if new_password != new_password_confirmation
56
54
 
57
- # Validate password length (configuration is a Range)
58
- password_range = PropelAuthentication.configuration.password_length
59
- return false unless password_range.include?(new_password.length)
55
+ if new_password.blank?
56
+ errors.add(:password, "cannot be blank")
57
+ return false
58
+ end
59
+
60
+ if new_password != new_password_confirmation
61
+ errors.add(:password_confirmation, "doesn't match Password")
62
+ return false
63
+ end
64
+
65
+ # Validate password length using Rails validations (populates errors)
66
+ self.password = new_password
67
+ unless valid?
68
+ return false # Rails validations populate errors automatically
69
+ end
60
70
 
61
71
  begin
62
72
  # Update password and clear failed login attempts
@@ -73,7 +83,7 @@ module Recoverable
73
83
 
74
84
  class_methods do
75
85
  # Find user by JWT password reset token
76
- def find_user_by_password_reset_token(token)
86
+ def find_by_jwt_password_reset_token(token)
77
87
  return nil if token.blank?
78
88
 
79
89
  begin
@@ -3,6 +3,21 @@
3
3
  ##
4
4
  # Module providing configuration detection and validation for PropelAuthentication generators
5
5
  #
6
+ # INDEPENDENCE ARCHITECTURE:
7
+ # - PropelAuthentication works completely standalone with sensible defaults
8
+ # - PropelApi integration is OPTIONAL - when both gems are present, PropelAuthentication
9
+ # adopts PropelApi's namespace/version for consistency
10
+ # - Neither gem requires the other as a dependency
11
+ # - Graceful fallback ensures no errors when either gem is missing
12
+ #
13
+ # STANDALONE DEFAULTS:
14
+ # - PropelAuthentication: Clean URLs (/login, /me, /signup) for simplicity
15
+ # - PropelApi: REST convention (/api/v1/resources) for standard API patterns
16
+ #
17
+ # INTEGRATION BENEFITS:
18
+ # - When both gems are used together, authentication routes automatically align
19
+ # with API namespace structure for consistent developer experience
20
+ #
6
21
  module PropelAuthentication
7
22
  module ConfigurationMethods
8
23
 
@@ -18,6 +33,11 @@ module PropelAuthentication
18
33
  default: nil,
19
34
  desc: "Authentication version (e.g., 'v1', 'v2'). Use 'none' for no versioning. Defaults to PropelAuthentication configuration."
20
35
 
36
+ base.class_option :auth_scope,
37
+ type: :string,
38
+ default: nil,
39
+ desc: "Authentication scope namespace (e.g., 'auth', 'admin'). Use 'none' for no auth scope. Defaults to nil for clean URLs."
40
+
21
41
  # Legacy support for existing --api-version option
22
42
  base.class_option :api_version,
23
43
  type: :string,
@@ -27,75 +47,67 @@ module PropelAuthentication
27
47
 
28
48
  protected
29
49
 
30
- # Initialize shared PropelAuthentication settings used across all generators
31
- def initialize_propel_auth_settings
32
- @auth_namespace = determine_auth_namespace
33
- @auth_version = determine_auth_version
34
- end
35
50
 
36
- # Determine the authentication namespace to use
37
- # Priority order:
38
- # 1. Command line option (--namespace)
39
- # 2. PropelAuthentication configuration (if exists)
40
- # 3. Default fallback (nil for clean URLs like /login)
41
- def determine_auth_namespace
42
- if options[:namespace] == 'none'
43
- return nil
44
- end
45
-
46
- if options[:namespace].present?
47
- return options[:namespace]
48
- end
49
-
50
- # Try to read from PropelAuthentication configuration
51
- begin
52
- if defined?(PropelAuthentication) && PropelAuthentication.configuration.respond_to?(:namespace)
53
- config_namespace = PropelAuthentication.configuration.namespace
54
- return config_namespace if config_namespace.present? && config_namespace != 'none'
55
- end
56
- rescue => e
57
- # Configuration not available, continue to default
58
- end
59
-
60
- # Default fallback - no namespace for clean URLs
61
- nil
51
+
52
+ # Single method to determine all authentication configuration
53
+ # Handles namespace, version, and auth_scope with consistent priority logic
54
+ def determine_configuration
55
+ @auth_namespace = determine_config_value(:namespace, nil)
56
+ @auth_version = determine_config_value(:version, nil)
57
+ @auth_scope = determine_config_value(:auth_scope, nil)
62
58
  end
63
59
 
64
- # Determine the authentication version to use
65
- # Priority order:
66
- # 1. Command line option (--version or legacy --api-version)
67
- # 2. PropelAuthentication configuration (if exists)
68
- # 3. Default fallback (nil for clean URLs like /login)
69
- def determine_auth_version
70
- # Check for 'none' in either option
71
- if options[:version] == 'none' || options[:api_version] == 'none'
60
+ # Generic configuration value determination with consistent priority
61
+ # Priority: 1) Command line, 2) PropelAuthentication config, 3) PropelApi config (optional), 4) Default
62
+ def determine_config_value(config_key, default_value)
63
+ option_key = config_key.to_s.gsub('_', '-').to_sym
64
+
65
+ # 1. Explicit command line override
66
+ if options[option_key] == 'none'
72
67
  return nil
73
68
  end
74
69
 
75
- # Check explicit version option first
76
- if options[:version].present?
77
- return options[:version]
70
+ if options[option_key].present?
71
+ return options[option_key]
78
72
  end
79
73
 
80
- # Check legacy api_version option
81
- if options[:api_version].present?
82
- return options[:api_version]
74
+ # Legacy support for --api-version
75
+ if config_key == :version && options[:api_version].present?
76
+ return options[:api_version] unless options[:api_version] == 'none'
83
77
  end
84
78
 
85
- # Try to read from PropelAuthentication configuration
79
+ # 2. Try PropelAuthentication configuration
86
80
  begin
87
- if defined?(PropelAuthentication) && PropelAuthentication.configuration.respond_to?(:version)
88
- config_version = PropelAuthentication.configuration.version
89
- return config_version if config_version.present? && config_version != 'none'
81
+ if defined?(PropelAuthentication) &&
82
+ PropelAuthentication.respond_to?(:configuration) &&
83
+ PropelAuthentication.configuration.respond_to?(config_key)
84
+ config_value = PropelAuthentication.configuration.send(config_key)
85
+ return config_value if config_value.present? && config_value != 'none'
90
86
  end
91
- rescue => e
92
- # Configuration not available, continue to default
87
+ rescue StandardError
88
+ # Configuration not available, continue
93
89
  end
94
90
 
95
- # Default fallback - no version for clean URLs
96
- nil
91
+ # 3. OPTIONAL: Try PropelApi for namespace/version consistency (when both gems are used)
92
+ if [:namespace, :version].include?(config_key)
93
+ begin
94
+ if defined?(PropelApi) &&
95
+ PropelApi.respond_to?(:configuration) &&
96
+ PropelApi.configuration.respond_to?(config_key)
97
+ api_value = PropelApi.configuration.send(config_key)
98
+ return api_value if api_value.present? && api_value != 'none'
99
+ end
100
+ rescue StandardError
101
+ # PropelApi not available - this is fine, PropelAuthentication works independently
102
+ end
103
+ end
104
+
105
+ # 4. Sensible default
106
+ default_value
97
107
  end
98
108
 
109
+
110
+
99
111
  # Display helpers for logging
100
112
  def namespace_display
101
113
  @auth_namespace.present? ? @auth_namespace : 'none'
@@ -105,21 +117,32 @@ module PropelAuthentication
105
117
  @auth_version.present? ? @auth_version : 'none'
106
118
  end
107
119
 
108
- # Authentication route path generation
120
+ def auth_scope_display
121
+ @auth_scope.present? ? @auth_scope : 'none'
122
+ end
123
+
124
+ # Authentication route path generation - single source of truth for complete route paths
109
125
  def auth_route_prefix
110
126
  path_parts = []
111
127
  path_parts << @auth_namespace if @auth_namespace.present?
112
128
  path_parts << @auth_version if @auth_version.present?
129
+ path_parts << @auth_scope if @auth_scope.present?
130
+
113
131
  return '/' if path_parts.empty?
114
132
  '/' + path_parts.join('/')
115
133
  end
116
134
 
117
- # Authentication controller namespace generation
118
- def auth_controller_namespace
135
+
136
+ # Single method to generate controller class names for all cases
137
+ # Handles both regular controllers and versioned controllers
138
+ def auth_controller_class_name(controller_type = 'tokens', version = nil)
119
139
  class_parts = []
120
140
  class_parts << @auth_namespace.camelize if @auth_namespace.present?
121
- class_parts << @auth_version.upcase if @auth_version.present?
122
- class_parts.empty? ? 'Auth' : class_parts.join('::') + '::Auth'
141
+ class_parts << (version || @auth_version).upcase if (version || @auth_version).present?
142
+ class_parts << @auth_scope.camelize if @auth_scope.present?
143
+ class_parts << "#{controller_type.camelize}Controller"
144
+
145
+ class_parts.join('::')
123
146
  end
124
147
 
125
148
  # Authentication controller directory path
@@ -140,12 +163,29 @@ module PropelAuthentication
140
163
  defined?(Rails.application.config.api_only) && Rails.application.config.api_only
141
164
  end
142
165
 
143
- def controller_namespace
144
- if api_versioned?
145
- auth_controller_namespace
146
- else
147
- 'Auth'
148
- end
166
+ # Extract namespace prefix for test class names (includes trailing :: when needed)
167
+ # E.g., "Api::V1::Auth::TokensController" → "Api::V1::Auth::"
168
+ # E.g., "TokensController" → "" (empty, no namespace)
169
+ def controller_namespace(controller_type)
170
+ controller_class = auth_controller_class_name(controller_type)
171
+ namespace_parts = controller_class.split('::')[0..-2] # Remove "TokensController" part
172
+
173
+ # Return namespace with trailing :: or empty string
174
+ namespace_parts.empty? ? '' : namespace_parts.join('::') + '::'
175
+ end
176
+
177
+ # Build dynamic controller file path based on current configuration
178
+ # E.g., Api::V1::Auth::TokensController → app/controllers/api/v1/auth/tokens_controller.rb
179
+ def controller_file_path(controller_type)
180
+ controller_class = auth_controller_class_name(controller_type)
181
+
182
+ # Convert class name to file path
183
+ path_parts = ['app', 'controllers']
184
+ namespace_parts = controller_class.split('::')[0..-2] # Remove "TokensController" part
185
+ namespace_parts.each { |part| path_parts << part.downcase }
186
+ path_parts << "#{controller_type}_controller.rb"
187
+
188
+ path_parts.join('/')
149
189
  end
150
190
  end
151
191
  end
@@ -8,12 +8,26 @@
8
8
  # MovieGenre.find_or_create_by!(name: genre_name)
9
9
  # end
10
10
 
11
- # Create test organization and user for authentication testing
11
+ # Create comprehensive tenancy structure for authentication testing
12
+ puts "🏢 Creating test organization structure..."
13
+
12
14
  organization = Organization.find_or_create_by!(name: 'Test Organization') do |org|
13
15
  org.website = 'https://test.example.com'
14
16
  org.time_zone = 'UTC'
15
17
  end
16
18
 
19
+ # Create agencies for proper tenancy structure
20
+ main_agency = Agency.find_or_create_by!(name: 'Main Department', organization: organization) do |agency|
21
+ agency.phone_number = '+1-555-0101'
22
+ agency.address = '123 Main Street'
23
+ end
24
+
25
+ support_agency = Agency.find_or_create_by!(name: 'Support Department', organization: organization) do |agency|
26
+ agency.phone_number = '+1-555-0102'
27
+ agency.address = '123 Support Street'
28
+ end
29
+
30
+ # Create test users with proper confirmations
17
31
  user = User.find_or_create_by!(email_address: 'test@example.com') do |u|
18
32
  u.username = 'testuser'
19
33
  u.email_address = 'test@example.com'
@@ -22,8 +36,40 @@ user = User.find_or_create_by!(email_address: 'test@example.com') do |u|
22
36
  u.organization = organization
23
37
  u.first_name = 'Test'
24
38
  u.last_name = 'User'
39
+ u.confirmed_at = 1.week.ago # Confirmed user for easier testing
40
+ end
41
+
42
+ admin_user = User.find_or_create_by!(email_address: 'admin@example.com') do |u|
43
+ u.username = 'adminuser'
44
+ u.email_address = 'admin@example.com'
45
+ u.password = 'password123'
46
+ u.password_confirmation = 'password123'
47
+ u.organization = organization
48
+ u.first_name = 'Admin'
49
+ u.last_name = 'User'
50
+ u.confirmed_at = 1.week.ago # Confirmed admin for easier testing
51
+ end
52
+
53
+ # Create agent associations for agency access (CRITICAL for tenancy validation)
54
+ Agent.find_or_create_by!(user: user, agency: main_agency) do |agent|
55
+ agent.role = 'member'
56
+ end
57
+
58
+ Agent.find_or_create_by!(user: admin_user, agency: main_agency) do |agent|
59
+ agent.role = 'manager'
60
+ end
61
+
62
+ Agent.find_or_create_by!(user: admin_user, agency: support_agency) do |agent|
63
+ agent.role = 'admin'
25
64
  end
26
65
 
27
- puts "Created test organization: #{organization.name}"
28
- puts "Created test user: #{user.email_address} (password: password123)"
29
- puts "You can now test login with POST /auth/login"
66
+ puts "Created test organization: #{organization.name}"
67
+ puts "Created agencies: #{Agency.where(organization: organization).pluck(:name).join(', ')}"
68
+ puts " Created test user: #{user.email_address} (password: password123)"
69
+ puts "✅ Created admin user: #{admin_user.email_address} (password: password123)"
70
+ puts "✅ Created agent associations for proper tenancy access"
71
+ puts ""
72
+ puts "🎯 Test user agency access: #{user.agency_ids}"
73
+ puts "🎯 Admin user agency access: #{admin_user.agency_ids}"
74
+ puts ""
75
+ puts "🚀 You can now test login with POST /api/v1/login"