agentcode 0.9.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.
- checksums.yaml +7 -0
- data/README.md +59 -0
- data/lib/agentcode/blueprint/blueprint_parser.rb +198 -0
- data/lib/agentcode/blueprint/blueprint_validator.rb +209 -0
- data/lib/agentcode/blueprint/generators/factory_generator.rb +74 -0
- data/lib/agentcode/blueprint/generators/policy_generator.rb +154 -0
- data/lib/agentcode/blueprint/generators/seeder_generator.rb +160 -0
- data/lib/agentcode/blueprint/generators/test_generator.rb +291 -0
- data/lib/agentcode/blueprint/manifest_manager.rb +81 -0
- data/lib/agentcode/commands/base_command.rb +57 -0
- data/lib/agentcode/commands/blueprint_command.rb +549 -0
- data/lib/agentcode/commands/export_postman_command.rb +328 -0
- data/lib/agentcode/commands/generate_command.rb +563 -0
- data/lib/agentcode/commands/install_command.rb +441 -0
- data/lib/agentcode/commands/invitation_link_command.rb +107 -0
- data/lib/agentcode/concerns/belongs_to_organization.rb +49 -0
- data/lib/agentcode/concerns/has_agentcode.rb +93 -0
- data/lib/agentcode/concerns/has_audit_trail.rb +125 -0
- data/lib/agentcode/concerns/has_auto_scope.rb +91 -0
- data/lib/agentcode/concerns/has_permissions.rb +117 -0
- data/lib/agentcode/concerns/has_uuid.rb +26 -0
- data/lib/agentcode/concerns/has_validation.rb +250 -0
- data/lib/agentcode/concerns/hidable_columns.rb +180 -0
- data/lib/agentcode/configuration.rb +98 -0
- data/lib/agentcode/controllers/auth_controller.rb +242 -0
- data/lib/agentcode/controllers/invitations_controller.rb +231 -0
- data/lib/agentcode/controllers/resources_controller.rb +813 -0
- data/lib/agentcode/engine.rb +65 -0
- data/lib/agentcode/mailers/invitation_mailer.rb +22 -0
- data/lib/agentcode/middleware/resolve_organization_from_route.rb +72 -0
- data/lib/agentcode/models/agentcode_model.rb +387 -0
- data/lib/agentcode/models/audit_log.rb +17 -0
- data/lib/agentcode/models/organization_invitation.rb +57 -0
- data/lib/agentcode/policies/invitation_policy.rb +54 -0
- data/lib/agentcode/policies/resource_policy.rb +197 -0
- data/lib/agentcode/query_builder.rb +278 -0
- data/lib/agentcode/railtie.rb +11 -0
- data/lib/agentcode/resource_scope.rb +59 -0
- data/lib/agentcode/routes.rb +124 -0
- data/lib/agentcode/tasks/agentcode.rake +39 -0
- data/lib/agentcode/templates/agentcode.rb +71 -0
- data/lib/agentcode/templates/agentcode_model.rb +104 -0
- data/lib/agentcode/templates/audit_trail/create_audit_logs.rb.erb +26 -0
- data/lib/agentcode/templates/generate/factory.rb.erb +43 -0
- data/lib/agentcode/templates/generate/migration.rb.erb +26 -0
- data/lib/agentcode/templates/generate/model.rb.erb +55 -0
- data/lib/agentcode/templates/generate/policy.rb.erb +52 -0
- data/lib/agentcode/templates/generate/scope.rb.erb +31 -0
- data/lib/agentcode/templates/multi_tenant/factories/organizations.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/roles.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
- data/lib/agentcode/templates/multi_tenant/factories/users.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/models/organization.rb.erb +18 -0
- data/lib/agentcode/templates/multi_tenant/models/role.rb.erb +11 -0
- data/lib/agentcode/templates/multi_tenant/models/user.rb.erb +14 -0
- data/lib/agentcode/templates/multi_tenant/models/user_role.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
- data/lib/agentcode/templates/routes.rb +13 -0
- data/lib/agentcode/version.rb +5 -0
- data/lib/agentcode/views/lumina/invitation_mailer/invite.html.erb +29 -0
- data/lib/agentcode-rails.rb +3 -0
- data/lib/agentcode.rb +26 -0
- metadata +281 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Column-level visibility control concern.
|
|
5
|
+
# Mirrors the Laravel HidableColumns trait.
|
|
6
|
+
#
|
|
7
|
+
# Base hidden columns: password, remember_token, created_at, updated_at,
|
|
8
|
+
# deleted_at, discarded_at, email_verified_at
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# class User < ApplicationRecord
|
|
12
|
+
# include AgentCode::HidableColumns
|
|
13
|
+
#
|
|
14
|
+
# agentcode_additional_hidden :secret_field, :internal_notes
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Adding computed attributes to JSON responses:
|
|
18
|
+
# class Comment < AgentCode::AgentCodeModel
|
|
19
|
+
# def author_name
|
|
20
|
+
# user&.name || 'Anonymous'
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# def agentcode_computed_attributes
|
|
24
|
+
# {
|
|
25
|
+
# 'author_name' => author_name
|
|
26
|
+
# }
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# Policy-based hiding:
|
|
31
|
+
# class UserPolicy < AgentCode::ResourcePolicy
|
|
32
|
+
# def hidden_attributes_for_show(user)
|
|
33
|
+
# has_role?(user, 'admin') ? [] : ['email', 'phone']
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# def permitted_attributes_for_show(user)
|
|
37
|
+
# has_role?(user, 'admin') ? ['*'] : ['id', 'name', 'avatar']
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
module HidableColumns
|
|
41
|
+
extend ActiveSupport::Concern
|
|
42
|
+
|
|
43
|
+
BASE_HIDDEN_COLUMNS = %w[
|
|
44
|
+
password
|
|
45
|
+
password_digest
|
|
46
|
+
remember_token
|
|
47
|
+
created_at
|
|
48
|
+
updated_at
|
|
49
|
+
deleted_at
|
|
50
|
+
discarded_at
|
|
51
|
+
email_verified_at
|
|
52
|
+
].freeze
|
|
53
|
+
|
|
54
|
+
included do
|
|
55
|
+
class_attribute :additional_hidden_columns, default: []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class_methods do
|
|
59
|
+
def agentcode_additional_hidden(*columns)
|
|
60
|
+
self.additional_hidden_columns = columns.map(&:to_s)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get the list of columns to hide for the current user.
|
|
65
|
+
# Merges base + static + policy-defined hidden columns.
|
|
66
|
+
# Resolves the user from RequestStore automatically.
|
|
67
|
+
#
|
|
68
|
+
# @return [Array<String>] Column names to hide
|
|
69
|
+
def hidden_columns_for(user = nil)
|
|
70
|
+
user ||= agentcode_current_user
|
|
71
|
+
columns = BASE_HIDDEN_COLUMNS.dup
|
|
72
|
+
columns.concat(additional_hidden_columns)
|
|
73
|
+
columns.concat(policy_hidden_columns(user))
|
|
74
|
+
columns.uniq
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Serialize to JSON excluding hidden columns and respecting policy whitelist.
|
|
78
|
+
#
|
|
79
|
+
# The current user is resolved automatically from RequestStore. Policy
|
|
80
|
+
# filtering (blacklist + whitelist) is applied AFTER computed attributes
|
|
81
|
+
# are merged, so computed attributes are always subject to policy control.
|
|
82
|
+
#
|
|
83
|
+
# Do NOT override this method. Override +agentcode_computed_attributes+ instead
|
|
84
|
+
# to add computed/virtual attributes to the JSON response.
|
|
85
|
+
#
|
|
86
|
+
# @return [Hash]
|
|
87
|
+
def as_agentcode_json
|
|
88
|
+
user = agentcode_current_user
|
|
89
|
+
hidden = hidden_columns_for(user)
|
|
90
|
+
result = as_json(except: hidden)
|
|
91
|
+
|
|
92
|
+
# Merge computed attributes from model BEFORE applying policy filtering
|
|
93
|
+
computed = agentcode_computed_attributes
|
|
94
|
+
result.merge!(computed) if computed.is_a?(Hash) && computed.any?
|
|
95
|
+
|
|
96
|
+
# Apply blacklist to the final hash (covers DB columns from as_json
|
|
97
|
+
# overrides AND computed attributes from agentcode_computed_attributes)
|
|
98
|
+
hidden_set = Set.new(hidden)
|
|
99
|
+
result.reject! { |key, _| hidden_set.include?(key) }
|
|
100
|
+
|
|
101
|
+
# Apply whitelist to the final hash (covers computed attributes too)
|
|
102
|
+
permitted = policy_permitted_attributes(user)
|
|
103
|
+
if permitted && permitted != ['*']
|
|
104
|
+
permitted_set = Set.new(permitted.map(&:to_s))
|
|
105
|
+
permitted_set.add('id') # id is always allowed
|
|
106
|
+
result.select! { |key, _| permitted_set.include?(key) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Override this method in your model to add computed/virtual attributes
|
|
113
|
+
# to the JSON response. These attributes are subject to policy-level
|
|
114
|
+
# blacklist (+hidden_attributes_for_show+) and whitelist
|
|
115
|
+
# (+permitted_attributes_for_show+) just like database columns.
|
|
116
|
+
#
|
|
117
|
+
# @example
|
|
118
|
+
# def agentcode_computed_attributes
|
|
119
|
+
# {
|
|
120
|
+
# 'full_name' => "#{first_name} #{last_name}",
|
|
121
|
+
# 'is_overdue' => due_date&.past?,
|
|
122
|
+
# 'days_until_expiry' => expiry_date ? (expiry_date - Date.current).to_i : nil
|
|
123
|
+
# }
|
|
124
|
+
# end
|
|
125
|
+
#
|
|
126
|
+
# @return [Hash] key-value pairs to merge into the JSON response
|
|
127
|
+
def agentcode_computed_attributes
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# Resolves the current user from RequestStore.
|
|
134
|
+
# @return [Object, nil]
|
|
135
|
+
def agentcode_current_user
|
|
136
|
+
RequestStore.store[:agentcode_current_user] if defined?(RequestStore)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Returns the permitted attributes list from the policy, or nil if no policy.
|
|
140
|
+
def policy_permitted_attributes(user)
|
|
141
|
+
policy_class = Pundit::PolicyFinder.new(self).policy
|
|
142
|
+
return nil unless policy_class
|
|
143
|
+
|
|
144
|
+
policy = policy_class.new(user, self)
|
|
145
|
+
if policy.respond_to?(:permitted_attributes_for_show)
|
|
146
|
+
policy.permitted_attributes_for_show(user)
|
|
147
|
+
end
|
|
148
|
+
rescue StandardError
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def policy_hidden_columns(user)
|
|
153
|
+
policy_class = Pundit::PolicyFinder.new(self).policy
|
|
154
|
+
return [] unless policy_class
|
|
155
|
+
|
|
156
|
+
policy = policy_class.new(user, self)
|
|
157
|
+
hidden = []
|
|
158
|
+
|
|
159
|
+
# Blacklist: hidden_attributes_for_show
|
|
160
|
+
if policy.respond_to?(:hidden_attributes_for_show)
|
|
161
|
+
hidden.concat(policy.hidden_attributes_for_show(user))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Whitelist: permitted_attributes_for_show
|
|
165
|
+
# Hide DB columns not in permitted list (computed attributes handled in as_agentcode_json)
|
|
166
|
+
if policy.respond_to?(:permitted_attributes_for_show)
|
|
167
|
+
permitted = policy.permitted_attributes_for_show(user)
|
|
168
|
+
if permitted != ['*']
|
|
169
|
+
all_columns = self.class.column_names
|
|
170
|
+
not_permitted = all_columns - permitted.map(&:to_s)
|
|
171
|
+
hidden.concat(not_permitted)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
hidden
|
|
176
|
+
rescue StandardError
|
|
177
|
+
[]
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :models, :route_groups, :multi_tenant, :invitations, :nested, :test_framework
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@models = {}
|
|
9
|
+
@route_groups = {}
|
|
10
|
+
@multi_tenant = {
|
|
11
|
+
organization_identifier_column: "id"
|
|
12
|
+
}
|
|
13
|
+
@invitations = {
|
|
14
|
+
expires_days: 7,
|
|
15
|
+
allowed_roles: nil
|
|
16
|
+
}
|
|
17
|
+
@nested = {
|
|
18
|
+
path: "nested",
|
|
19
|
+
max_operations: 50,
|
|
20
|
+
allowed_models: nil
|
|
21
|
+
}
|
|
22
|
+
@test_framework = "rspec"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Register a model with its slug
|
|
26
|
+
# Usage: config.model :posts, 'Post'
|
|
27
|
+
def model(slug, klass_name)
|
|
28
|
+
@models[slug.to_sym] = klass_name.to_s
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Register a route group with its configuration
|
|
32
|
+
# Usage: config.route_group :tenant, prefix: ':organization', middleware: [AgentCode::Middleware::ResolveOrganizationFromRoute], models: :all
|
|
33
|
+
def route_group(name, prefix: "", middleware: [], models: :all)
|
|
34
|
+
@route_groups[name.to_sym] = {
|
|
35
|
+
prefix: prefix.to_s,
|
|
36
|
+
middleware: Array(middleware),
|
|
37
|
+
models: models
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve a model class from its slug
|
|
42
|
+
def resolve_model(slug)
|
|
43
|
+
klass_name = @models[slug.to_sym]
|
|
44
|
+
raise ActiveRecord::RecordNotFound, "The #{slug} model does not exist" unless klass_name
|
|
45
|
+
|
|
46
|
+
klass = klass_name.constantize
|
|
47
|
+
raise ActiveRecord::RecordNotFound, "The #{slug} model does not exist" unless klass
|
|
48
|
+
|
|
49
|
+
klass
|
|
50
|
+
rescue NameError
|
|
51
|
+
raise ActiveRecord::RecordNotFound, "The #{slug} model does not exist"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Find the slug for a given model class
|
|
55
|
+
def slug_for(model_class)
|
|
56
|
+
class_name = model_class.is_a?(Class) ? model_class.name : model_class.class.name
|
|
57
|
+
@models.each do |slug, klass_name|
|
|
58
|
+
return slug if klass_name == class_name
|
|
59
|
+
end
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Whether a 'tenant' route group is configured
|
|
64
|
+
def has_tenant_group?
|
|
65
|
+
@route_groups.key?(:tenant)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether a 'public' route group is configured
|
|
69
|
+
def has_public_group?
|
|
70
|
+
@route_groups.key?(:public)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resolve the model slugs for a given route group
|
|
74
|
+
def models_for_group(group_name)
|
|
75
|
+
group = @route_groups[group_name.to_sym]
|
|
76
|
+
return [] unless group
|
|
77
|
+
|
|
78
|
+
group_models = group[:models]
|
|
79
|
+
if group_models == :all || group_models == "*"
|
|
80
|
+
@models.keys
|
|
81
|
+
else
|
|
82
|
+
Array(group_models).map(&:to_sym) & @models.keys
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check if a model belongs to the 'public' route group
|
|
87
|
+
def public_model?(slug)
|
|
88
|
+
return false unless has_public_group?
|
|
89
|
+
|
|
90
|
+
models_for_group(:public).include?(slug.to_sym)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Check if a specific slug belongs to a specific group
|
|
94
|
+
def model_in_group?(slug, group_name)
|
|
95
|
+
models_for_group(group_name).include?(slug.to_sym)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
# Authentication controller — mirrors Laravel AuthController exactly.
|
|
5
|
+
#
|
|
6
|
+
# Endpoints:
|
|
7
|
+
# POST /api/auth/login
|
|
8
|
+
# POST /api/auth/logout
|
|
9
|
+
# POST /api/auth/password/recover
|
|
10
|
+
# POST /api/auth/password/reset
|
|
11
|
+
# POST /api/auth/register
|
|
12
|
+
class AuthController < ActionController::API
|
|
13
|
+
before_action :authenticate_user!, only: [:logout]
|
|
14
|
+
|
|
15
|
+
# POST /api/auth/login
|
|
16
|
+
def login
|
|
17
|
+
email = params[:email].to_s.strip
|
|
18
|
+
password = params[:password].to_s
|
|
19
|
+
|
|
20
|
+
if email.blank? || password.blank?
|
|
21
|
+
return render json: { message: "Invalid credentials" }, status: :unauthorized
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
user_class = "User".safe_constantize
|
|
25
|
+
return render json: { message: "Invalid credentials" }, status: :unauthorized unless user_class
|
|
26
|
+
|
|
27
|
+
user = user_class.find_by(email: email)
|
|
28
|
+
|
|
29
|
+
unless user&.authenticate(password)
|
|
30
|
+
return render json: { message: "Invalid credentials" }, status: :unauthorized
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
token = generate_api_token(user)
|
|
34
|
+
|
|
35
|
+
# Get the first organization the user belongs to
|
|
36
|
+
organization_slug = nil
|
|
37
|
+
if user.respond_to?(:organizations)
|
|
38
|
+
first_org = user.organizations.first
|
|
39
|
+
organization_slug = first_org&.slug
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
render json: {
|
|
43
|
+
token: token,
|
|
44
|
+
organization_slug: organization_slug
|
|
45
|
+
}, status: :ok
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# POST /api/auth/logout
|
|
49
|
+
def logout
|
|
50
|
+
user = current_user
|
|
51
|
+
|
|
52
|
+
if user.respond_to?(:regenerate_api_token)
|
|
53
|
+
user.regenerate_api_token
|
|
54
|
+
elsif user.respond_to?(:update_column) && user.class.column_names.include?("api_token")
|
|
55
|
+
user.update_column(:api_token, SecureRandom.hex(32))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
render json: { message: "Logged out successfully" }, status: :ok
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# POST /api/auth/password/recover
|
|
62
|
+
def recover_password
|
|
63
|
+
email = params[:email].to_s.strip
|
|
64
|
+
|
|
65
|
+
if email.blank?
|
|
66
|
+
return render json: { errors: { email: ["The email field is required."] } }, status: :unprocessable_entity
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
user_class = "User".safe_constantize
|
|
70
|
+
user = user_class&.find_by(email: email)
|
|
71
|
+
|
|
72
|
+
if user
|
|
73
|
+
token = SecureRandom.hex(32)
|
|
74
|
+
|
|
75
|
+
# Store reset token
|
|
76
|
+
if user.respond_to?(:update_columns)
|
|
77
|
+
user.update_columns(
|
|
78
|
+
reset_password_token: token,
|
|
79
|
+
reset_password_sent_at: Time.current
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Send email via mailer if available
|
|
84
|
+
mailer_class = "AgentCode::PasswordRecoveryMailer".safe_constantize
|
|
85
|
+
mailer_class&.recover(user, token)&.deliver_later
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Always return success to prevent email enumeration
|
|
89
|
+
render json: { message: "Password recovery email sent." }, status: :ok
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# POST /api/auth/password/reset
|
|
93
|
+
def reset
|
|
94
|
+
errors = {}
|
|
95
|
+
errors[:token] = ["The token field is required."] if params[:token].blank?
|
|
96
|
+
errors[:email] = ["The email field is required."] if params[:email].blank?
|
|
97
|
+
errors[:password] = ["The password field is required."] if params[:password].blank?
|
|
98
|
+
|
|
99
|
+
if params[:password].present? && params[:password].length < 8
|
|
100
|
+
errors[:password] = ["The password must be at least 8 characters."]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if params[:password].present? && params[:password] != params[:password_confirmation]
|
|
104
|
+
errors[:password_confirmation] = ["The password confirmation does not match."]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
unless errors.empty?
|
|
108
|
+
return render json: { errors: errors }, status: :unprocessable_entity
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
user_class = "User".safe_constantize
|
|
112
|
+
user = user_class&.find_by(email: params[:email])
|
|
113
|
+
|
|
114
|
+
unless user
|
|
115
|
+
return render json: { message: "Token is invalid or expired." }, status: :bad_request
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Verify token
|
|
119
|
+
valid_token = user.respond_to?(:reset_password_token) &&
|
|
120
|
+
user.reset_password_token == params[:token] &&
|
|
121
|
+
user.respond_to?(:reset_password_sent_at) &&
|
|
122
|
+
user.reset_password_sent_at.present? &&
|
|
123
|
+
user.reset_password_sent_at > 1.hour.ago
|
|
124
|
+
|
|
125
|
+
unless valid_token
|
|
126
|
+
return render json: { message: "Token is invalid or expired." }, status: :bad_request
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Update password
|
|
130
|
+
user.password = params[:password]
|
|
131
|
+
user.reset_password_token = nil
|
|
132
|
+
user.reset_password_sent_at = nil
|
|
133
|
+
user.save!
|
|
134
|
+
|
|
135
|
+
render json: { message: "Password has been reset." }, status: :ok
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# POST /api/auth/register
|
|
139
|
+
def register_with_invitation
|
|
140
|
+
errors = {}
|
|
141
|
+
errors[:token] = ["The token field is required."] if params[:token].blank?
|
|
142
|
+
errors[:name] = ["The name field is required."] if params[:name].blank?
|
|
143
|
+
errors[:email] = ["The email field is required."] if params[:email].blank?
|
|
144
|
+
errors[:password] = ["The password field is required."] if params[:password].blank?
|
|
145
|
+
|
|
146
|
+
if params[:password].present? && params[:password].length < 8
|
|
147
|
+
errors[:password] = ["The password must be at least 8 characters."]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if params[:password].present? && params[:password] != params[:password_confirmation]
|
|
151
|
+
errors[:password_confirmation] = ["The password confirmation does not match."]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
user_class = "User".safe_constantize
|
|
155
|
+
if user_class && params[:email].present? && user_class.exists?(email: params[:email])
|
|
156
|
+
errors[:email] = ["The email has already been taken."]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
unless errors.empty?
|
|
160
|
+
return render json: { errors: errors }, status: :unprocessable_entity
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Find invitation
|
|
164
|
+
invitation = OrganizationInvitation.find_by(token: params[:token], status: "pending")
|
|
165
|
+
|
|
166
|
+
unless invitation
|
|
167
|
+
return render json: { message: "Invalid or expired invitation token" }, status: :not_found
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if invitation.expired?
|
|
171
|
+
invitation.update!(status: "expired")
|
|
172
|
+
return render json: { message: "This invitation has expired" }, status: :unprocessable_entity
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Validate email matches invitation
|
|
176
|
+
unless invitation.email == params[:email]
|
|
177
|
+
return render json: { message: "Email does not match the invitation" }, status: :unprocessable_entity
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Create user
|
|
181
|
+
user = user_class.create!(
|
|
182
|
+
name: params[:name],
|
|
183
|
+
email: params[:email],
|
|
184
|
+
password: params[:password]
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Accept invitation (adds user to organization)
|
|
188
|
+
invitation.accept!(user)
|
|
189
|
+
|
|
190
|
+
# Generate token
|
|
191
|
+
token = generate_api_token(user)
|
|
192
|
+
|
|
193
|
+
# Get organization slug for redirect
|
|
194
|
+
organization = invitation.organization
|
|
195
|
+
organization_slug = organization&.slug
|
|
196
|
+
|
|
197
|
+
render json: {
|
|
198
|
+
message: "Registration successful",
|
|
199
|
+
token: token,
|
|
200
|
+
user: user.as_json(except: %w[password_digest api_token reset_password_token]),
|
|
201
|
+
organization_slug: organization_slug
|
|
202
|
+
}, status: :created
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def authenticate_user!
|
|
208
|
+
unless current_user
|
|
209
|
+
render json: { message: "Unauthenticated." }, status: :unauthorized
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def current_user
|
|
214
|
+
@current_user ||= begin
|
|
215
|
+
token = request.headers["Authorization"]&.sub(/\ABearer /, "")
|
|
216
|
+
return nil unless token
|
|
217
|
+
|
|
218
|
+
user_class = "User".safe_constantize
|
|
219
|
+
return nil unless user_class
|
|
220
|
+
|
|
221
|
+
if user_class.respond_to?(:find_by_api_token)
|
|
222
|
+
user_class.find_by_api_token(token)
|
|
223
|
+
elsif user_class.column_names.include?("api_token")
|
|
224
|
+
user_class.find_by(api_token: token)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def generate_api_token(user)
|
|
230
|
+
if user.respond_to?(:regenerate_api_token)
|
|
231
|
+
user.regenerate_api_token
|
|
232
|
+
user.api_token
|
|
233
|
+
elsif user.class.column_names.include?("api_token")
|
|
234
|
+
token = SecureRandom.hex(32)
|
|
235
|
+
user.update_column(:api_token, token)
|
|
236
|
+
token
|
|
237
|
+
else
|
|
238
|
+
SecureRandom.hex(32)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|