Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ group :development do

# Docker-based deployment [https://kamal-deploy.org]
gem "kamal", require: false

# Process manager for bin/dev (web + Tailwind watcher) [https://github.com/ddollar/foreman]
gem "foreman", require: false
end

group :test do
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ GEM
ffi (1.17.4-x86_64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
foreman (0.90.0)
thor (~> 1.4)
fugit (1.12.2)
et-orbi (~> 1.4)
raabro (~> 1.4)
Expand Down Expand Up @@ -482,6 +484,7 @@ DEPENDENCIES
debug
factory_bot_rails
faker
foreman
groupdate
image_processing (~> 1.2)
importmap-rails
Expand Down Expand Up @@ -568,6 +571,7 @@ CHECKSUMS
ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
foreman (0.90.0) sha256=ff675e2d47b607ac58714a6d4ac3e1ee8f06f41d8db084531c31961e2c3f117c
fugit (1.12.2) sha256=643f2bf28db263bd400cbf8e0dd8b76b2c9b94bdb130e12d2394de04d9c20e5e
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
groupdate (6.8.0) sha256=16f90ea18ca981c38e41e592416480b28353aa5e5cc3e7cb13a68b8fb5d40510
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ that keeps it from happening again.
🚧 In active development. Roadmap:

- [x] Phase 0 — Foundations: tooling, test suite, CI, security scanning
- [ ] Phase 1 — Fleet core: accounts, auth, vehicles (VIN decoding), drivers, assignments
- [x] Phase 1 — Fleet core: accounts, auth, vehicles (VIN decoding), drivers, assignments
- [ ] Phase 2 — Maintenance: service records, schedules, renewals, alert engine
- [ ] Phase 3 — Integrations: Twilio SMS, SendGrid email, Stripe subscriptions
- [ ] Phase 4 — Reports: cost dashboards, fuel tracking, demo seeds
Expand Down
16 changes: 16 additions & 0 deletions app/channels/application_cable/connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user

def connect
set_current_user || reject_unauthorized_connection
end

private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
end
22 changes: 22 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
class ApplicationController < ActionController::Base
include Authentication
include Pundit::Authorization
include Pagy::Method

# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern

# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes

rescue_from Pundit::NotAuthorizedError, with: :forbidden

helper_method :current_account

private

def current_account
Current.account
end

def pundit_user
Current.user
end

def forbidden
redirect_back fallback_location: root_path, alert: "You are not allowed to do that."
end
end
54 changes: 54 additions & 0 deletions app/controllers/assignments_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
class AssignmentsController < ApplicationController
before_action :set_assignment, only: %i[edit update destroy]

def new
@assignment = current_account.assignments.new(
vehicle_id: params[:vehicle_id], driver_id: params[:driver_id], started_on: Date.current
)
authorize @assignment
end

def create
@assignment = current_account.assignments.new(assignment_params)
authorize @assignment

if @assignment.save
redirect_to @assignment.vehicle, notice: "Driver assigned."
else
render :new, status: :unprocessable_entity
end
end

def edit
end

def update
if @assignment.update(assignment_params)
redirect_to @assignment.vehicle, notice: "Assignment updated."
else
render :edit, status: :unprocessable_entity
end
end

def destroy
vehicle = @assignment.vehicle
@assignment.destroy!
redirect_to vehicle, notice: "Assignment removed."
end

private

def set_assignment
@assignment = current_account.assignments.find(params[:id])
authorize @assignment
end

def assignment_params
permitted = params.expect(assignment: [ :vehicle_id, :driver_id, :started_on, :ended_on ])
# Resolve associations through the tenant so foreign ids can't escape the account.
permitted.merge(
vehicle: current_account.vehicles.find(permitted[:vehicle_id]),
driver: current_account.drivers.find(permitted[:driver_id])
).except(:vehicle_id, :driver_id)
end
end
52 changes: 52 additions & 0 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module Authentication
extend ActiveSupport::Concern

included do
before_action :require_authentication
helper_method :authenticated?
end

class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end

private
def authenticated?
resume_session
end

def require_authentication
resume_session || request_authentication
end

def resume_session
Current.session ||= find_session_by_cookie
end

def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end

def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end

def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end

def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end

def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
end
57 changes: 57 additions & 0 deletions app/controllers/drivers_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
class DriversController < ApplicationController
before_action :set_driver, only: %i[show edit update destroy]

def index
authorize Driver
drivers = current_account.drivers.search(params[:q])
drivers = drivers.where(status: params[:status]) if params[:status].present?
@pagy, @drivers = pagy(drivers.order(:name))
end

def show
@assignments = @driver.assignments.includes(:vehicle).order(started_on: :desc)
end

def new
@driver = current_account.drivers.new
authorize @driver
end

def create
@driver = current_account.drivers.new(driver_params)
authorize @driver

if @driver.save
redirect_to @driver, notice: "Driver added."
else
render :new, status: :unprocessable_entity
end
end

def edit
end

def update
if @driver.update(driver_params)
redirect_to @driver, notice: "Driver updated."
else
render :edit, status: :unprocessable_entity
end
end

def destroy
@driver.destroy!
redirect_to drivers_path, notice: "Driver removed."
end

private

def set_driver
@driver = current_account.drivers.find(params[:id])
authorize @driver
end

def driver_params
params.expect(driver: [ :name, :email, :phone, :license_number, :license_expires_on, :status ])
end
end
35 changes: 35 additions & 0 deletions app/controllers/passwords_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
class PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }

def new
end

def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end

redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end

def edit
end

def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end

private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end
25 changes: 25 additions & 0 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class RegistrationsController < ApplicationController
allow_unauthenticated_access
rate_limit to: 5, within: 1.minute, only: :create

def new
@registration = Registration.new
end

def create
@registration = Registration.new(registration_params)

if @registration.save
start_new_session_for @registration.user
redirect_to root_path, notice: "Welcome to FleetPilot, #{@registration.account.name}!"
else
render :new, status: :unprocessable_entity
end
end

private

def registration_params
params.expect(registration: [ :account_name, :email_address, :password, :password_confirmation ])
end
end
21 changes: 21 additions & 0 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }

def new
end

def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end

def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
end
Loading
Loading