propel_authentication 0.1.1
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/LICENSE +21 -0
- data/README.md +290 -0
- data/Rakefile +12 -0
- data/lib/generators/propel_auth/install_generator.rb +486 -0
- data/lib/generators/propel_auth/pack_generator.rb +277 -0
- data/lib/generators/propel_auth/templates/agency.rb +7 -0
- data/lib/generators/propel_auth/templates/agent.rb +7 -0
- data/lib/generators/propel_auth/templates/auth/base_passwords_controller.rb.tt +99 -0
- data/lib/generators/propel_auth/templates/auth/base_tokens_controller.rb.tt +90 -0
- data/lib/generators/propel_auth/templates/auth/passwords_controller.rb.tt +126 -0
- data/lib/generators/propel_auth/templates/auth_mailer.rb +180 -0
- data/lib/generators/propel_auth/templates/authenticatable.rb +38 -0
- data/lib/generators/propel_auth/templates/concerns/confirmable.rb +145 -0
- data/lib/generators/propel_auth/templates/concerns/lockable.rb +123 -0
- data/lib/generators/propel_auth/templates/concerns/propel_authentication.rb +44 -0
- data/lib/generators/propel_auth/templates/concerns/rack_session_disable.rb +19 -0
- data/lib/generators/propel_auth/templates/concerns/recoverable.rb +124 -0
- data/lib/generators/propel_auth/templates/config/environments/development_email.rb +43 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_agencies.rb +20 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_agents.rb +11 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_invitations.rb +28 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_organizations.rb +18 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_users.rb +43 -0
- data/lib/generators/propel_auth/templates/db/seeds.rb +29 -0
- data/lib/generators/propel_auth/templates/invitation.rb +133 -0
- data/lib/generators/propel_auth/templates/lib/propel_auth.rb +84 -0
- data/lib/generators/propel_auth/templates/organization.rb +7 -0
- data/lib/generators/propel_auth/templates/propel_auth.rb +132 -0
- data/lib/generators/propel_auth/templates/services/auth_notification_service.rb +89 -0
- data/lib/generators/propel_auth/templates/test/concerns/confirmable_test.rb.tt +247 -0
- data/lib/generators/propel_auth/templates/test/concerns/lockable_test.rb.tt +282 -0
- data/lib/generators/propel_auth/templates/test/concerns/propel_authentication_test.rb.tt +75 -0
- data/lib/generators/propel_auth/templates/test/concerns/recoverable_test.rb.tt +327 -0
- data/lib/generators/propel_auth/templates/test/controllers/auth/lockable_integration_test.rb.tt +196 -0
- data/lib/generators/propel_auth/templates/test/controllers/auth/password_reset_integration_test.rb.tt +471 -0
- data/lib/generators/propel_auth/templates/test/controllers/auth/tokens_controller_test.rb.tt +265 -0
- data/lib/generators/propel_auth/templates/test/mailers/auth_mailer_test.rb.tt +216 -0
- data/lib/generators/propel_auth/templates/test/mailers/previews/auth_mailer_preview.rb +161 -0
- data/lib/generators/propel_auth/templates/tokens_controller.rb.tt +96 -0
- data/lib/generators/propel_auth/templates/user.rb +21 -0
- data/lib/generators/propel_auth/templates/user_test.rb.tt +81 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/account_unlock.html.erb +213 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/account_unlock.text.erb +56 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/email_confirmation.html.erb +213 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/email_confirmation.text.erb +32 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/password_reset.html.erb +166 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/password_reset.text.erb +32 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/user_invitation.html.erb +194 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/user_invitation.text.erb +51 -0
- data/lib/generators/propel_auth/test/dummy/Dockerfile +72 -0
- data/lib/generators/propel_auth/test/dummy/Gemfile +63 -0
- data/lib/generators/propel_auth/test/dummy/Gemfile.lock +394 -0
- data/lib/generators/propel_auth/test/dummy/README.md +24 -0
- data/lib/generators/propel_auth/test/dummy/Rakefile +6 -0
- data/lib/generators/propel_auth/test/dummy/app/assets/stylesheets/application.css +10 -0
- data/lib/generators/propel_auth/test/dummy/app/controllers/application_controller.rb +4 -0
- data/lib/generators/propel_auth/test/dummy/app/helpers/application_helper.rb +2 -0
- data/lib/generators/propel_auth/test/dummy/app/jobs/application_job.rb +7 -0
- data/lib/generators/propel_auth/test/dummy/app/mailers/application_mailer.rb +4 -0
- data/lib/generators/propel_auth/test/dummy/app/models/application_record.rb +3 -0
- data/lib/generators/propel_auth/test/dummy/app/views/layouts/application.html.erb +27 -0
- data/lib/generators/propel_auth/test/dummy/app/views/layouts/mailer.html.erb +13 -0
- data/lib/generators/propel_auth/test/dummy/app/views/layouts/mailer.text.erb +1 -0
- data/lib/generators/propel_auth/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/lib/generators/propel_auth/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/lib/generators/propel_auth/test/dummy/bin/brakeman +7 -0
- data/lib/generators/propel_auth/test/dummy/bin/dev +2 -0
- data/lib/generators/propel_auth/test/dummy/bin/docker-entrypoint +14 -0
- data/lib/generators/propel_auth/test/dummy/bin/rails +4 -0
- data/lib/generators/propel_auth/test/dummy/bin/rake +4 -0
- data/lib/generators/propel_auth/test/dummy/bin/rubocop +8 -0
- data/lib/generators/propel_auth/test/dummy/bin/setup +34 -0
- data/lib/generators/propel_auth/test/dummy/bin/thrust +5 -0
- data/lib/generators/propel_auth/test/dummy/config/application.rb +42 -0
- data/lib/generators/propel_auth/test/dummy/config/boot.rb +4 -0
- data/lib/generators/propel_auth/test/dummy/config/cable.yml +10 -0
- data/lib/generators/propel_auth/test/dummy/config/credentials.yml.enc +1 -0
- data/lib/generators/propel_auth/test/dummy/config/database.yml +41 -0
- data/lib/generators/propel_auth/test/dummy/config/environment.rb +5 -0
- data/lib/generators/propel_auth/test/dummy/config/environments/development.rb +72 -0
- data/lib/generators/propel_auth/test/dummy/config/environments/production.rb +89 -0
- data/lib/generators/propel_auth/test/dummy/config/environments/test.rb +53 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/assets.rb +10 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/content_security_policy.rb +25 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/inflections.rb +16 -0
- data/lib/generators/propel_auth/test/dummy/config/locales/en.yml +31 -0
- data/lib/generators/propel_auth/test/dummy/config/master.key +1 -0
- data/lib/generators/propel_auth/test/dummy/config/puma.rb +41 -0
- data/lib/generators/propel_auth/test/dummy/config/routes.rb +2 -0
- data/lib/generators/propel_auth/test/dummy/config/storage.yml +34 -0
- data/lib/generators/propel_auth/test/dummy/config.ru +6 -0
- data/lib/generators/propel_auth/test/dummy/db/schema.rb +14 -0
- data/lib/generators/propel_auth/test/generators/authentication/controllers/tokens_controller_test.rb +230 -0
- data/lib/generators/propel_auth/test/generators/authentication/install_generator_test.rb +490 -0
- data/lib/generators/propel_auth/test/generators/authentication/uninstall_generator_test.rb +408 -0
- data/lib/generators/propel_auth/test/integration/generator_integration_test.rb +158 -0
- data/lib/generators/propel_auth/test/integration/multi_version_generator_test.rb +125 -0
- data/lib/generators/propel_auth/unpack_generator.rb +345 -0
- data/lib/propel_auth.rb +3 -0
- metadata +195 -0
@@ -0,0 +1,96 @@
|
|
1
|
+
<%- if api_versioned? -%>
|
2
|
+
# Dynamic API versioning controller - inherits from base controller
|
3
|
+
class <%= controller_namespace %>::TokensController < Api::Auth::BaseTokensController
|
4
|
+
end
|
5
|
+
<%- else -%>
|
6
|
+
class <%= controller_namespace %>::TokensController < ApplicationController
|
7
|
+
<%- unless api_only_app? -%>
|
8
|
+
include RackSessionDisable
|
9
|
+
<%- end -%>
|
10
|
+
include PropelAuthentication
|
11
|
+
|
12
|
+
# POST <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/login
|
13
|
+
def create
|
14
|
+
user = User.find_by(email_address: params[:user][:email_address])
|
15
|
+
|
16
|
+
if user&.authenticate(params[:user][:password])
|
17
|
+
if user.status == 1 # inactive
|
18
|
+
render json: { error: 'Account is inactive' }, status: :unauthorized
|
19
|
+
return
|
20
|
+
end
|
21
|
+
|
22
|
+
# Update last login timestamp
|
23
|
+
user.update(last_login_at: Time.current)
|
24
|
+
|
25
|
+
# Reset failed login attempts on successful login
|
26
|
+
user.reset_failed_attempts! if user.respond_to?(:reset_failed_attempts!)
|
27
|
+
|
28
|
+
render json: {
|
29
|
+
token: user.generate_jwt_token,
|
30
|
+
user: user_response(user)
|
31
|
+
}, status: :ok
|
32
|
+
else
|
33
|
+
# Increment failed login attempts if user exists and has lockable functionality
|
34
|
+
if user&.respond_to?(:increment_failed_attempts!)
|
35
|
+
user.increment_failed_attempts!
|
36
|
+
end
|
37
|
+
|
38
|
+
render json: { error: 'Invalid credentials' }, status: :unauthorized
|
39
|
+
end
|
40
|
+
rescue ActionController::ParameterMissing
|
41
|
+
render json: { error: 'Missing required parameters' }, status: :unprocessable_entity
|
42
|
+
end
|
43
|
+
|
44
|
+
# DELETE <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/logout
|
45
|
+
def destroy
|
46
|
+
# For JWT, logout is handled client-side by removing the token
|
47
|
+
# Server-side logout would require token blacklisting (future enhancement)
|
48
|
+
render json: { message: 'Logged out successfully' }, status: :ok
|
49
|
+
end
|
50
|
+
|
51
|
+
# GET <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/me
|
52
|
+
def me
|
53
|
+
return unless authenticate_user
|
54
|
+
render json: { user: user_response(current_user) }, status: :ok
|
55
|
+
end
|
56
|
+
|
57
|
+
def refresh
|
58
|
+
render json: { token: @current_user.generate_jwt_token }
|
59
|
+
end
|
60
|
+
|
61
|
+
# POST <%= api_versioned? ? "/#{api_namespace}" : "" %>/auth/unlock
|
62
|
+
def unlock
|
63
|
+
token = params[:token]
|
64
|
+
|
65
|
+
if token.blank?
|
66
|
+
render json: { error: 'Token is required' }, status: :unprocessable_entity
|
67
|
+
return
|
68
|
+
end
|
69
|
+
|
70
|
+
user = User.find_by_unlock_token(token)
|
71
|
+
|
72
|
+
if user
|
73
|
+
user.unlock_account!
|
74
|
+
render json: { message: 'Account unlocked successfully' }, status: :ok
|
75
|
+
else
|
76
|
+
render json: { error: 'Invalid or expired unlock token' }, status: :unauthorized
|
77
|
+
end
|
78
|
+
rescue => e
|
79
|
+
render json: { error: 'Invalid unlock token' }, status: :unauthorized
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def user_response(user)
|
85
|
+
{
|
86
|
+
id: user.id,
|
87
|
+
email_address: user.email_address,
|
88
|
+
username: user.username,
|
89
|
+
first_name: user.first_name,
|
90
|
+
last_name: user.last_name,
|
91
|
+
organization_id: user.organization_id,
|
92
|
+
last_login_at: user.last_login_at
|
93
|
+
}
|
94
|
+
end
|
95
|
+
end
|
96
|
+
<%- end -%>
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class User < ApplicationRecord
|
2
|
+
# Multi-tenant associations
|
3
|
+
belongs_to :organization
|
4
|
+
has_many :invitations, foreign_key: :inviter_id, dependent: :destroy
|
5
|
+
has_many :agents, dependent: :destroy
|
6
|
+
|
7
|
+
# Validations
|
8
|
+
validates :email_address, presence: true, uniqueness: true
|
9
|
+
validates :username, presence: true, uniqueness: true
|
10
|
+
validates :organization, presence: true
|
11
|
+
|
12
|
+
# Authentication and security concerns
|
13
|
+
include Authenticatable
|
14
|
+
include Lockable
|
15
|
+
include Recoverable
|
16
|
+
include Confirmable
|
17
|
+
|
18
|
+
# Future enhancements (Epic 2)
|
19
|
+
# include Invitable
|
20
|
+
# include Statusable
|
21
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
class UserTest < ActiveSupport::TestCase
|
4
|
+
def setup
|
5
|
+
@organization = Organization.create!(name: "Test Organization")
|
6
|
+
@user_attributes = {
|
7
|
+
email_address: "test@example.com",
|
8
|
+
username: "testuser",
|
9
|
+
password: "password123",
|
10
|
+
organization: @organization
|
11
|
+
}
|
12
|
+
end
|
13
|
+
|
14
|
+
test "should be valid with all attributes" do
|
15
|
+
user = User.new(@user_attributes)
|
16
|
+
assert user.valid?, "User should be valid with all required attributes"
|
17
|
+
end
|
18
|
+
|
19
|
+
test "should authenticate with correct password" do
|
20
|
+
user = User.create!(@user_attributes)
|
21
|
+
assert user.authenticate("password123")
|
22
|
+
assert_not user.authenticate("wrongpassword")
|
23
|
+
end
|
24
|
+
|
25
|
+
test "should not save user without email_address" do
|
26
|
+
user = User.new(@user_attributes.except(:email_address))
|
27
|
+
assert_not user.save, "Saved the user without an email_address"
|
28
|
+
end
|
29
|
+
|
30
|
+
test "should not save user without username" do
|
31
|
+
user = User.new(@user_attributes.except(:username))
|
32
|
+
assert_not user.save, "Saved the user without a username"
|
33
|
+
end
|
34
|
+
|
35
|
+
test "should not save user with short password" do
|
36
|
+
user = User.new(@user_attributes.merge(password: "123"))
|
37
|
+
assert_not user.save, "Saved the user with a password that is too short"
|
38
|
+
end
|
39
|
+
|
40
|
+
test "should generate valid JWT token" do
|
41
|
+
user = User.create!(@user_attributes)
|
42
|
+
|
43
|
+
token = user.generate_jwt_token
|
44
|
+
assert token.present?, "Should generate JWT token"
|
45
|
+
|
46
|
+
# Verify token structure
|
47
|
+
assert_equal 3, token.split('.').length, "JWT should have 3 parts"
|
48
|
+
|
49
|
+
# Verify token content
|
50
|
+
payload = JWT.decode(token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })[0]
|
51
|
+
assert_equal user.id, payload['user_id'], "Token should include user ID"
|
52
|
+
assert_equal user.organization_id, payload['organization_id'], "Token should include organization ID"
|
53
|
+
assert payload['exp'].present?, "Token should include expiration"
|
54
|
+
end
|
55
|
+
|
56
|
+
test "should validate JWT token and find user" do
|
57
|
+
user = User.create!(@user_attributes)
|
58
|
+
|
59
|
+
token = user.generate_jwt_token
|
60
|
+
found_user = User.find_by_jwt_token(token)
|
61
|
+
|
62
|
+
assert_equal user.id, found_user.id, "Should find user by JWT token"
|
63
|
+
assert_equal user.email_address, found_user.email_address, "Should return correct user"
|
64
|
+
end
|
65
|
+
|
66
|
+
test "should validate email_address uniqueness" do
|
67
|
+
User.create!(@user_attributes)
|
68
|
+
|
69
|
+
user2 = User.new(@user_attributes.merge(username: "newuser"))
|
70
|
+
|
71
|
+
assert_not user2.save, "Should not save user with duplicate email_address"
|
72
|
+
assert user2.errors[:email_address].present?, "Should have email_address validation error"
|
73
|
+
end
|
74
|
+
|
75
|
+
test "should validate password strength" do
|
76
|
+
user = User.new(@user_attributes.merge(password: "weak"))
|
77
|
+
|
78
|
+
assert_not user.save, "Should not save user with weak password"
|
79
|
+
assert user.errors[:password].present?, "Should have password validation error"
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,213 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta charset="utf-8">
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6
|
+
<title>Account Locked - Security Notification</title>
|
7
|
+
<style>
|
8
|
+
body {
|
9
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
10
|
+
line-height: 1.6;
|
11
|
+
color: #333;
|
12
|
+
max-width: 600px;
|
13
|
+
margin: 0 auto;
|
14
|
+
padding: 20px;
|
15
|
+
background-color: #f8f9fa;
|
16
|
+
}
|
17
|
+
.container {
|
18
|
+
background-color: #ffffff;
|
19
|
+
padding: 40px;
|
20
|
+
border-radius: 8px;
|
21
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
22
|
+
}
|
23
|
+
.header {
|
24
|
+
text-align: center;
|
25
|
+
margin-bottom: 30px;
|
26
|
+
}
|
27
|
+
.logo {
|
28
|
+
font-size: 24px;
|
29
|
+
font-weight: bold;
|
30
|
+
color: #007bff;
|
31
|
+
margin-bottom: 10px;
|
32
|
+
}
|
33
|
+
h1 {
|
34
|
+
color: #dc3545;
|
35
|
+
font-size: 28px;
|
36
|
+
margin-bottom: 20px;
|
37
|
+
text-align: center;
|
38
|
+
}
|
39
|
+
.greeting {
|
40
|
+
font-size: 18px;
|
41
|
+
margin-bottom: 20px;
|
42
|
+
}
|
43
|
+
.message {
|
44
|
+
margin-bottom: 30px;
|
45
|
+
line-height: 1.8;
|
46
|
+
}
|
47
|
+
.cta-button {
|
48
|
+
display: inline-block;
|
49
|
+
background-color: #28a745;
|
50
|
+
color: white;
|
51
|
+
padding: 15px 30px;
|
52
|
+
text-decoration: none;
|
53
|
+
border-radius: 5px;
|
54
|
+
font-weight: bold;
|
55
|
+
text-align: center;
|
56
|
+
margin: 20px 0;
|
57
|
+
}
|
58
|
+
.cta-button:hover {
|
59
|
+
background-color: #218838;
|
60
|
+
}
|
61
|
+
.security-notice {
|
62
|
+
background-color: #f8d7da;
|
63
|
+
border: 1px solid #f5c6cb;
|
64
|
+
border-radius: 5px;
|
65
|
+
padding: 20px;
|
66
|
+
margin: 20px 0;
|
67
|
+
}
|
68
|
+
.security-notice h3 {
|
69
|
+
color: #721c24;
|
70
|
+
margin-top: 0;
|
71
|
+
}
|
72
|
+
.warning-notice {
|
73
|
+
background-color: #fff3cd;
|
74
|
+
border: 1px solid #ffeaa7;
|
75
|
+
border-radius: 5px;
|
76
|
+
padding: 20px;
|
77
|
+
margin: 20px 0;
|
78
|
+
}
|
79
|
+
.warning-notice h3 {
|
80
|
+
color: #856404;
|
81
|
+
margin-top: 0;
|
82
|
+
}
|
83
|
+
.expiry-notice {
|
84
|
+
background-color: #d1ecf1;
|
85
|
+
border: 1px solid #bee5eb;
|
86
|
+
border-radius: 5px;
|
87
|
+
padding: 15px;
|
88
|
+
margin: 20px 0;
|
89
|
+
text-align: center;
|
90
|
+
}
|
91
|
+
.footer {
|
92
|
+
margin-top: 40px;
|
93
|
+
padding-top: 20px;
|
94
|
+
border-top: 1px solid #eee;
|
95
|
+
font-size: 14px;
|
96
|
+
color: #666;
|
97
|
+
}
|
98
|
+
.token-info {
|
99
|
+
background-color: #f8f9fa;
|
100
|
+
border: 1px solid #dee2e6;
|
101
|
+
border-radius: 5px;
|
102
|
+
padding: 15px;
|
103
|
+
margin: 20px 0;
|
104
|
+
font-family: monospace;
|
105
|
+
word-break: break-all;
|
106
|
+
}
|
107
|
+
.auto-unlock-info {
|
108
|
+
background-color: #d4edda;
|
109
|
+
border: 1px solid #c3e6cb;
|
110
|
+
border-radius: 5px;
|
111
|
+
padding: 15px;
|
112
|
+
margin: 20px 0;
|
113
|
+
}
|
114
|
+
@media (max-width: 600px) {
|
115
|
+
.container {
|
116
|
+
padding: 20px;
|
117
|
+
}
|
118
|
+
.cta-button {
|
119
|
+
display: block;
|
120
|
+
width: 100%;
|
121
|
+
text-align: center;
|
122
|
+
}
|
123
|
+
}
|
124
|
+
</style>
|
125
|
+
</head>
|
126
|
+
<body>
|
127
|
+
<div class="container">
|
128
|
+
<div class="header">
|
129
|
+
<div class="logo"><%= @organization_name %></div>
|
130
|
+
</div>
|
131
|
+
|
132
|
+
<h1>🔒 Account Locked - Security Notification</h1>
|
133
|
+
|
134
|
+
<div class="greeting">
|
135
|
+
Hello <%= @display_name %>,
|
136
|
+
</div>
|
137
|
+
|
138
|
+
<div class="security-notice">
|
139
|
+
<h3>🚨 Security Alert</h3>
|
140
|
+
<p><strong>Your account has been temporarily locked</strong> due to multiple failed login attempts.</p>
|
141
|
+
<p>This is an automated security measure to protect your account from unauthorized access.</p>
|
142
|
+
</div>
|
143
|
+
|
144
|
+
<div class="message">
|
145
|
+
<p><strong>Account Details:</strong></p>
|
146
|
+
<ul>
|
147
|
+
<li><strong>Email:</strong> <%= @user.email_address %></li>
|
148
|
+
<li><strong>Organization:</strong> <%= @organization_name %></li>
|
149
|
+
<li><strong>Lock Time:</strong> <%= @user.locked_at&.strftime("%B %d, %Y at %I:%M %p %Z") %></li>
|
150
|
+
</ul>
|
151
|
+
</div>
|
152
|
+
|
153
|
+
<div class="warning-notice">
|
154
|
+
<h3>⚠️ What happened?</h3>
|
155
|
+
<p>Your account was locked after multiple failed login attempts. This could be due to:</p>
|
156
|
+
<ul>
|
157
|
+
<li>Forgotten password</li>
|
158
|
+
<li>Someone attempting to access your account</li>
|
159
|
+
<li>Browser auto-fill using an old password</li>
|
160
|
+
</ul>
|
161
|
+
</div>
|
162
|
+
|
163
|
+
<div class="message">
|
164
|
+
<p><strong>To unlock your account immediately, click the button below:</strong></p>
|
165
|
+
</div>
|
166
|
+
|
167
|
+
<div style="text-align: center;">
|
168
|
+
<a href="<%= @unlock_url %>" class="cta-button">Unlock My Account</a>
|
169
|
+
</div>
|
170
|
+
|
171
|
+
<div class="expiry-notice">
|
172
|
+
⏰ <strong>This unlock link will expire in <%= @expiration_hours %> hour<%= @expiration_hours > 1 ? 's' : '' %></strong> for your security.
|
173
|
+
</div>
|
174
|
+
|
175
|
+
<div class="auto-unlock-info">
|
176
|
+
<h3>🕐 Automatic Unlock</h3>
|
177
|
+
<p><strong>Don't want to use the link?</strong> Your account will automatically unlock after 30 minutes from the lock time.</p>
|
178
|
+
<p>You can try logging in again after: <strong><%= (@user.locked_at + 30.minutes).strftime("%B %d, %Y at %I:%M %p %Z") %></strong></p>
|
179
|
+
</div>
|
180
|
+
|
181
|
+
<div class="security-notice">
|
182
|
+
<h3>🛡️ Security Recommendations</h3>
|
183
|
+
<ul>
|
184
|
+
<li><strong>Change your password</strong> if you suspect unauthorized access</li>
|
185
|
+
<li><strong>Use a password manager</strong> to avoid repeated failed attempts</li>
|
186
|
+
<li><strong>Contact support</strong> if you continue having issues</li>
|
187
|
+
<li><strong>Review recent activity</strong> in your account settings</li>
|
188
|
+
</ul>
|
189
|
+
</div>
|
190
|
+
|
191
|
+
<div class="message">
|
192
|
+
<p><strong>If the button doesn't work, copy and paste this link into your browser:</strong></p>
|
193
|
+
<div class="token-info">
|
194
|
+
<%= @unlock_url %>
|
195
|
+
</div>
|
196
|
+
</div>
|
197
|
+
|
198
|
+
<div class="footer">
|
199
|
+
<p><strong>Need Help?</strong></p>
|
200
|
+
<p>If you didn't attempt to log in or suspect unauthorized access, please contact our support team immediately at <a href="mailto:<%= @support_email %>"><%= @support_email %></a></p>
|
201
|
+
|
202
|
+
<p>This security notification was sent to <%= @user.email_address %> for your <%= @organization_name %> account.</p>
|
203
|
+
|
204
|
+
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
|
205
|
+
|
206
|
+
<p style="font-size: 12px; color: #999;">
|
207
|
+
<strong><%= @organization_name %></strong><br>
|
208
|
+
This is an automated security notification. Please do not reply directly to this email.
|
209
|
+
</p>
|
210
|
+
</div>
|
211
|
+
</div>
|
212
|
+
</body>
|
213
|
+
</html>
|
@@ -0,0 +1,56 @@
|
|
1
|
+
ACCOUNT LOCKED - SECURITY NOTIFICATION - <%= @organization_name %>
|
2
|
+
=========================================================
|
3
|
+
|
4
|
+
Hello <%= @display_name %>,
|
5
|
+
|
6
|
+
🚨 SECURITY ALERT
|
7
|
+
------------------
|
8
|
+
Your account has been temporarily locked due to multiple failed login attempts.
|
9
|
+
This is an automated security measure to protect your account from unauthorized access.
|
10
|
+
|
11
|
+
ACCOUNT DETAILS
|
12
|
+
---------------
|
13
|
+
• Email: <%= @user.email_address %>
|
14
|
+
• Organization: <%= @organization_name %>
|
15
|
+
• Lock Time: <%= @user.locked_at&.strftime("%B %d, %Y at %I:%M %p %Z") %>
|
16
|
+
|
17
|
+
⚠️ WHAT HAPPENED?
|
18
|
+
------------------
|
19
|
+
Your account was locked after multiple failed login attempts. This could be due to:
|
20
|
+
• Forgotten password
|
21
|
+
• Someone attempting to access your account
|
22
|
+
• Browser auto-fill using an old password
|
23
|
+
|
24
|
+
UNLOCK YOUR ACCOUNT IMMEDIATELY
|
25
|
+
--------------------------------
|
26
|
+
To unlock your account right now, visit this link:
|
27
|
+
|
28
|
+
<%= @unlock_url %>
|
29
|
+
|
30
|
+
⏰ IMPORTANT: This unlock link will expire in <%= @expiration_hours %> hour<%= @expiration_hours > 1 ? 's' : '' %> for your security.
|
31
|
+
|
32
|
+
🕐 AUTOMATIC UNLOCK
|
33
|
+
-------------------
|
34
|
+
Don't want to use the link? Your account will automatically unlock after 30 minutes from the lock time.
|
35
|
+
|
36
|
+
You can try logging in again after: <%= (@user.locked_at + 30.minutes).strftime("%B %d, %Y at %I:%M %p %Z") %>
|
37
|
+
|
38
|
+
🛡️ SECURITY RECOMMENDATIONS
|
39
|
+
----------------------------
|
40
|
+
• Change your password if you suspect unauthorized access
|
41
|
+
• Use a password manager to avoid repeated failed attempts
|
42
|
+
• Contact support if you continue having issues
|
43
|
+
• Review recent activity in your account settings
|
44
|
+
|
45
|
+
NEED HELP?
|
46
|
+
----------
|
47
|
+
If you didn't attempt to log in or suspect unauthorized access, please contact our support team immediately at <%= @support_email %>
|
48
|
+
|
49
|
+
For security reasons, this unlock link can only be used once and will expire in <%= @expiration_hours %> hour<%= @expiration_hours > 1 ? 's' : '' %>.
|
50
|
+
|
51
|
+
---
|
52
|
+
|
53
|
+
<%= @organization_name %>
|
54
|
+
This is an automated security notification. Please do not reply directly to this email.
|
55
|
+
|
56
|
+
This security notification was sent to <%= @user.email_address %> for your <%= @organization_name %> account.
|
@@ -0,0 +1,213 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
5
|
+
<style>
|
6
|
+
/* Reset styles */
|
7
|
+
body {
|
8
|
+
margin: 0;
|
9
|
+
padding: 0;
|
10
|
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
11
|
+
font-size: 16px;
|
12
|
+
line-height: 1.6;
|
13
|
+
color: #333;
|
14
|
+
background-color: #f8f9fa;
|
15
|
+
}
|
16
|
+
|
17
|
+
/* Container */
|
18
|
+
.email-container {
|
19
|
+
max-width: 600px;
|
20
|
+
margin: 0 auto;
|
21
|
+
background-color: #ffffff;
|
22
|
+
border-radius: 8px;
|
23
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
24
|
+
overflow: hidden;
|
25
|
+
}
|
26
|
+
|
27
|
+
/* Header */
|
28
|
+
.header {
|
29
|
+
background-color: #007bff;
|
30
|
+
color: #ffffff;
|
31
|
+
text-align: center;
|
32
|
+
padding: 30px 20px;
|
33
|
+
}
|
34
|
+
|
35
|
+
.header h1 {
|
36
|
+
margin: 0;
|
37
|
+
font-size: 28px;
|
38
|
+
font-weight: 300;
|
39
|
+
}
|
40
|
+
|
41
|
+
/* Content */
|
42
|
+
.content {
|
43
|
+
padding: 40px 30px;
|
44
|
+
}
|
45
|
+
|
46
|
+
.content h2 {
|
47
|
+
color: #333;
|
48
|
+
font-size: 24px;
|
49
|
+
margin-bottom: 20px;
|
50
|
+
font-weight: 400;
|
51
|
+
}
|
52
|
+
|
53
|
+
.content p {
|
54
|
+
margin-bottom: 20px;
|
55
|
+
line-height: 1.7;
|
56
|
+
}
|
57
|
+
|
58
|
+
/* Confirmation button */
|
59
|
+
.confirm-button {
|
60
|
+
display: inline-block;
|
61
|
+
background-color: #28a745;
|
62
|
+
color: #ffffff;
|
63
|
+
text-decoration: none;
|
64
|
+
padding: 15px 30px;
|
65
|
+
border-radius: 5px;
|
66
|
+
font-weight: 600;
|
67
|
+
text-align: center;
|
68
|
+
margin: 20px 0;
|
69
|
+
transition: background-color 0.3s ease;
|
70
|
+
}
|
71
|
+
|
72
|
+
.confirm-button:hover {
|
73
|
+
background-color: #218838;
|
74
|
+
}
|
75
|
+
|
76
|
+
/* Token section */
|
77
|
+
.token-section {
|
78
|
+
background-color: #f8f9fa;
|
79
|
+
border-left: 4px solid #007bff;
|
80
|
+
padding: 15px 20px;
|
81
|
+
margin: 20px 0;
|
82
|
+
border-radius: 4px;
|
83
|
+
}
|
84
|
+
|
85
|
+
.token-code {
|
86
|
+
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
87
|
+
font-size: 14px;
|
88
|
+
color: #6c757d;
|
89
|
+
word-break: break-all;
|
90
|
+
}
|
91
|
+
|
92
|
+
/* Security info */
|
93
|
+
.security-info {
|
94
|
+
background-color: #fff3cd;
|
95
|
+
border: 1px solid #ffeaa7;
|
96
|
+
border-radius: 4px;
|
97
|
+
padding: 15px;
|
98
|
+
margin: 20px 0;
|
99
|
+
}
|
100
|
+
|
101
|
+
.security-info h3 {
|
102
|
+
color: #856404;
|
103
|
+
margin: 0 0 10px 0;
|
104
|
+
font-size: 16px;
|
105
|
+
}
|
106
|
+
|
107
|
+
.security-info p {
|
108
|
+
color: #856404;
|
109
|
+
margin: 0;
|
110
|
+
font-size: 14px;
|
111
|
+
}
|
112
|
+
|
113
|
+
/* Footer */
|
114
|
+
.footer {
|
115
|
+
background-color: #f8f9fa;
|
116
|
+
padding: 20px 30px;
|
117
|
+
text-align: center;
|
118
|
+
border-top: 1px solid #e9ecef;
|
119
|
+
}
|
120
|
+
|
121
|
+
.footer p {
|
122
|
+
margin: 0;
|
123
|
+
font-size: 14px;
|
124
|
+
color: #6c757d;
|
125
|
+
}
|
126
|
+
|
127
|
+
.footer a {
|
128
|
+
color: #007bff;
|
129
|
+
text-decoration: none;
|
130
|
+
}
|
131
|
+
|
132
|
+
/* Responsive */
|
133
|
+
@media only screen and (max-width: 600px) {
|
134
|
+
.email-container {
|
135
|
+
margin: 0;
|
136
|
+
border-radius: 0;
|
137
|
+
}
|
138
|
+
|
139
|
+
.content {
|
140
|
+
padding: 30px 20px;
|
141
|
+
}
|
142
|
+
|
143
|
+
.header {
|
144
|
+
padding: 20px;
|
145
|
+
}
|
146
|
+
|
147
|
+
.header h1 {
|
148
|
+
font-size: 24px;
|
149
|
+
}
|
150
|
+
}
|
151
|
+
</style>
|
152
|
+
</head>
|
153
|
+
<body>
|
154
|
+
<div class="email-container">
|
155
|
+
<!-- Header -->
|
156
|
+
<div class="header">
|
157
|
+
<h1><%= @organization_name %></h1>
|
158
|
+
</div>
|
159
|
+
|
160
|
+
<!-- Content -->
|
161
|
+
<div class="content">
|
162
|
+
<h2>Welcome <%= @display_name %>! 👋</h2>
|
163
|
+
|
164
|
+
<p>Thank you for signing up! To get started, please confirm your email address by clicking the button below:</p>
|
165
|
+
|
166
|
+
<div style="text-align: center;">
|
167
|
+
<a href="<%= confirmation_url(@user) %>" class="confirm-button">
|
168
|
+
Confirm My Email Address
|
169
|
+
</a>
|
170
|
+
</div>
|
171
|
+
|
172
|
+
<p>This confirmation link will expire in <strong>24 hours</strong> for security reasons.</p>
|
173
|
+
|
174
|
+
<!-- Alternative method -->
|
175
|
+
<p>If the button above doesn't work, you can copy and paste this link into your browser:</p>
|
176
|
+
|
177
|
+
<div class="token-section">
|
178
|
+
<p style="margin: 0;"><strong>Confirmation Link:</strong></p>
|
179
|
+
<p class="token-code"><%= confirmation_url(@user) %></p>
|
180
|
+
</div>
|
181
|
+
|
182
|
+
<!-- Security information -->
|
183
|
+
<div class="security-info">
|
184
|
+
<h3>🔒 Security Notice</h3>
|
185
|
+
<p>If you didn't create an account with us, please ignore this email. Your email address will not be added to our system.</p>
|
186
|
+
</div>
|
187
|
+
|
188
|
+
<p>Once confirmed, you'll be able to:</p>
|
189
|
+
<ul>
|
190
|
+
<li>Access your account dashboard</li>
|
191
|
+
<li>Update your profile settings</li>
|
192
|
+
<li>Receive important notifications</li>
|
193
|
+
<li>Take advantage of all platform features</li>
|
194
|
+
</ul>
|
195
|
+
|
196
|
+
<p>Need help? Feel free to contact our support team.</p>
|
197
|
+
</div>
|
198
|
+
|
199
|
+
<!-- Footer -->
|
200
|
+
<div class="footer">
|
201
|
+
<p>
|
202
|
+
<strong><%= @organization_name %></strong><br>
|
203
|
+
<a href="mailto:<%= @support_email %>">Contact Support</a> |
|
204
|
+
<a href="<%= unsubscribe_url(@user) if respond_to?(:unsubscribe_url) %>">Unsubscribe</a>
|
205
|
+
</p>
|
206
|
+
<p style="margin-top: 10px; font-size: 12px;">
|
207
|
+
This email was sent to <%= @email_address || @user.email_address %>.
|
208
|
+
If you have questions, contact us at <a href="mailto:<%= @support_email %>"><%= @support_email %></a>.
|
209
|
+
</p>
|
210
|
+
</div>
|
211
|
+
</div>
|
212
|
+
</body>
|
213
|
+
</html>
|