nextjs-rewrite #5
@@ -17,6 +17,8 @@ gem "rom", "~> 5.3"
|
|||||||
gem "rom-sql", "~> 3.6"
|
gem "rom-sql", "~> 3.6"
|
||||||
gem "sqlite3"
|
gem "sqlite3"
|
||||||
|
|
||||||
|
gem "bcrypt", "~> 3.1"
|
||||||
|
|
||||||
gem "good_job", "~> 4.13"
|
gem "good_job", "~> 4.13"
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
|
|||||||
@@ -5,5 +5,43 @@ require "hanami/action"
|
|||||||
|
|
||||||
module EuchreCamp
|
module EuchreCamp
|
||||||
class Action < Hanami::Action
|
class Action < Hanami::Action
|
||||||
|
include Deps[users_repo: 'repositories.users', players_repo: 'repositories.players']
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
# Get current user from session
|
||||||
|
def current_user(request)
|
||||||
|
user_id = request.session[:user_id]
|
||||||
|
return nil unless user_id
|
||||||
|
|
||||||
|
users_repo.by_id(user_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get current player from session
|
||||||
|
def current_player(request)
|
||||||
|
player_id = request.session[:player_id]
|
||||||
|
return nil unless player_id
|
||||||
|
|
||||||
|
players_repo.by_id(player_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if user is logged in
|
||||||
|
def logged_in?(request)
|
||||||
|
!request.session[:user_id].nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Require authentication - redirect to login if not logged in
|
||||||
|
def require_authentication(request, response)
|
||||||
|
unless logged_in?(request)
|
||||||
|
response.flash[:error] = 'Please login to continue'
|
||||||
|
response.redirect_to '/login'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to pass current_player to view
|
||||||
|
def render_with_player(request, response, **options)
|
||||||
|
player = current_player(request)
|
||||||
|
response.render(view, current_player: player, **options)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module EuchreCamp
|
||||||
|
module Actions
|
||||||
|
module Auth
|
||||||
|
class Login < EuchreCamp::Action
|
||||||
|
include Deps[
|
||||||
|
users_repo: 'repositories.users',
|
||||||
|
players_repo: 'repositories.players'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Show login form (GET)
|
||||||
|
class Show < Login
|
||||||
|
def handle(request, response)
|
||||||
|
render_with_player(request, response)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Process login (POST)
|
||||||
|
class Create < Login
|
||||||
|
def handle(request, response)
|
||||||
|
email = request.params[:email]
|
||||||
|
password = request.params[:password]
|
||||||
|
|
||||||
|
user = users_repo.by_email(email)
|
||||||
|
|
||||||
|
if user && valid_password?(user, password)
|
||||||
|
if user.locked?
|
||||||
|
response.flash[:error] = 'Account is locked. Please contact support.'
|
||||||
|
response.redirect_to '/login'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Record successful login
|
||||||
|
ip_address = request.env['REMOTE_ADDR']
|
||||||
|
users_repo.record_login(user[:id], ip_address)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session = request.session
|
||||||
|
session[:user_id] = user[:id]
|
||||||
|
session[:player_id] = user[:player_id]
|
||||||
|
|
||||||
|
response.flash[:success] = 'Welcome back!'
|
||||||
|
response.redirect_to '/rankings'
|
||||||
|
else
|
||||||
|
# Record failed login
|
||||||
|
users_repo.record_failed_login(user[:id]) if user
|
||||||
|
|
||||||
|
response.flash[:error] = 'Invalid email or password'
|
||||||
|
response.redirect_to '/login'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def valid_password?(user, password)
|
||||||
|
BCrypt::Password.new(user[:password_digest]) == password
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module EuchreCamp
|
||||||
|
module Actions
|
||||||
|
module Auth
|
||||||
|
class Logout < EuchreCamp::Action
|
||||||
|
def handle(request, response)
|
||||||
|
request.session.clear
|
||||||
|
response.redirect_to '/rankings'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module EuchreCamp
|
||||||
|
module Repositories
|
||||||
|
class Users < ::EuchreCamp::Repository[:users]
|
||||||
|
|
||||||
|
# Find user by ID
|
||||||
|
def by_id(id)
|
||||||
|
users.by_pk(id).one
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find user by email
|
||||||
|
def by_email(email)
|
||||||
|
users.where(email: email).one
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find user by player ID
|
||||||
|
def by_player_id(player_id)
|
||||||
|
users.where(player_id: player_id).one
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
def create(email:, password_digest:, player_id:)
|
||||||
|
users.changeset(:create, {
|
||||||
|
email: email,
|
||||||
|
password_digest: password_digest,
|
||||||
|
player_id: player_id,
|
||||||
|
confirmed: false,
|
||||||
|
confirmation_token: generate_token,
|
||||||
|
confirmation_sent_at: Time.now
|
||||||
|
}).commit
|
||||||
|
end
|
||||||
|
|
||||||
|
# Confirm user account
|
||||||
|
def confirm(token)
|
||||||
|
user = users.where(confirmation_token: token).one
|
||||||
|
return nil unless user
|
||||||
|
|
||||||
|
users.where(id: user[:id]).update(
|
||||||
|
confirmed: true,
|
||||||
|
confirmation_token: nil,
|
||||||
|
confirmation_sent_at: nil
|
||||||
|
)
|
||||||
|
user
|
||||||
|
end
|
||||||
|
|
||||||
|
# Request password reset
|
||||||
|
def request_password_reset(email)
|
||||||
|
user = by_email(email)
|
||||||
|
return nil unless user
|
||||||
|
|
||||||
|
token = generate_token
|
||||||
|
users.where(id: user[:id]).update(
|
||||||
|
reset_password_token: token,
|
||||||
|
reset_password_sent_at: Time.now
|
||||||
|
)
|
||||||
|
token
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reset password
|
||||||
|
def reset_password(token, new_password_digest)
|
||||||
|
user = users.where(reset_password_token: token).one
|
||||||
|
return nil unless user
|
||||||
|
|
||||||
|
# Check if token is expired (1 hour)
|
||||||
|
if user[:reset_password_sent_at] && user[:reset_password_sent_at] < (Time.now - 3600)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
users.where(id: user[:id]).update(
|
||||||
|
password_digest: new_password_digest,
|
||||||
|
reset_password_token: nil,
|
||||||
|
reset_password_sent_at: nil
|
||||||
|
)
|
||||||
|
user
|
||||||
|
end
|
||||||
|
|
||||||
|
# Record successful login
|
||||||
|
def record_login(user_id, ip_address)
|
||||||
|
users.where(id: user_id).update(
|
||||||
|
last_login_at: Time.now,
|
||||||
|
last_login_ip: ip_address,
|
||||||
|
failed_login_attempts: 0,
|
||||||
|
locked_until: nil
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Record failed login
|
||||||
|
def record_failed_login(user_id)
|
||||||
|
user = users.where(id: user_id).one
|
||||||
|
return unless user
|
||||||
|
|
||||||
|
attempts = user[:failed_login_attempts] + 1
|
||||||
|
locked_until = attempts >= 5 ? (Time.now + 900) : nil # Lock for 15 minutes after 5 attempts
|
||||||
|
|
||||||
|
users.where(id: user_id).update(
|
||||||
|
failed_login_attempts: attempts,
|
||||||
|
locked_until: locked_until
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if account is locked
|
||||||
|
def account_locked?(user_id)
|
||||||
|
user = users.where(id: user_id).one
|
||||||
|
return false unless user
|
||||||
|
|
||||||
|
user[:locked_until] && user[:locked_until] > Time.now
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_token
|
||||||
|
SecureRandom.urlsafe_base64(32)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<div class="auth-container">
|
||||||
|
<div class="auth-card">
|
||||||
|
<h1>Login</h1>
|
||||||
|
|
||||||
|
<% if flash[:error] %>
|
||||||
|
<div class="alert alert-error"><%= flash[:error] %></div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if flash[:success] %>
|
||||||
|
<div class="alert alert-success"><%= flash[:success] %></div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<form action="/auth/login" method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required autocomplete="email">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group remember-me">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="remember" value="1">
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-links">
|
||||||
|
<p>Don't have an account? <a href="/register">Register here</a></p>
|
||||||
|
<p><a href="/password/reset">Forgot password?</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
ROM::SQL.migration do
|
||||||
|
change do
|
||||||
|
create_table :users do
|
||||||
|
primary_key :id
|
||||||
|
foreign_key :player_id, :players, null: false
|
||||||
|
column :email, String, null: false, unique: true
|
||||||
|
column :password_digest, String, null: false
|
||||||
|
column :confirmed, Boolean, default: false
|
||||||
|
column :confirmation_token, String
|
||||||
|
column :confirmation_sent_at, DateTime
|
||||||
|
column :reset_password_token, String
|
||||||
|
column :reset_password_sent_at, DateTime
|
||||||
|
column :failed_login_attempts, Integer, default: 0
|
||||||
|
column :locked_until, DateTime
|
||||||
|
column :last_login_at, DateTime
|
||||||
|
column :last_login_ip, String
|
||||||
|
column :created_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||||
|
column :updated_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :users, :email, unique: true
|
||||||
|
add_index :users, :confirmation_token
|
||||||
|
add_index :users, :reset_password_token
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module EuchreCamp
|
||||||
|
module Entities
|
||||||
|
class User < Dry::Struct
|
||||||
|
attribute :id, Types::Integer
|
||||||
|
attribute :player_id, Types::Integer
|
||||||
|
attribute :email, Types::String
|
||||||
|
attribute :password_digest, Types::String
|
||||||
|
attribute :confirmed, Types::Bool
|
||||||
|
attribute? :confirmation_token, Types::String.optional
|
||||||
|
attribute? :confirmation_sent_at, Types::Time.optional
|
||||||
|
attribute? :reset_password_token, Types::String.optional
|
||||||
|
attribute? :reset_password_sent_at, Types::Time.optional
|
||||||
|
attribute :failed_login_attempts, Types::Integer
|
||||||
|
attribute? :locked_until, Types::Time.optional
|
||||||
|
attribute? :last_login_at, Types::Time.optional
|
||||||
|
attribute? :last_login_ip, Types::String.optional
|
||||||
|
attribute :created_at, Types::Time
|
||||||
|
attribute :updated_at, Types::Time
|
||||||
|
|
||||||
|
# Check if account is locked
|
||||||
|
def locked?
|
||||||
|
locked_until && locked_until > Time.now
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if account is confirmed
|
||||||
|
def active?
|
||||||
|
confirmed && !locked?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if user is admin (for navigation purposes)
|
||||||
|
# This would be enhanced with actual role checking later
|
||||||
|
def admin?
|
||||||
|
false # Placeholder - implement role checking later
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user