My opinionated ruby on rails template
1# frozen_string_literal: true
2
3say 'Setting up custom authentication...', :green
4say ' Adding bcrypt gem...', :cyan
5
6gem 'bcrypt', '~> 3.1'
7
8# Generate models after bundle install
9after_bundle do
10 say ' Generating User model...', :cyan
11 generate :model, 'User email:string:uniq password_digest:string role:integer'
12
13 say ' Generating Session model...', :cyan
14 generate :model, 'Session user:references token:string:uniq ip_address:string user_agent:string'
15end
16
17# User model with has_secure_password and roles
18file 'app/models/user.rb', <<~RUBY, force: true
19 class User < ApplicationRecord
20 include PublicIdentifiable
21 set_public_id_prefix :usr
22
23 has_secure_password
24
25 enum :role, { user: 0, admin: 1, super_admin: 2, owner: 3 }, default: :user
26
27 normalizes :email, with: ->(email) { email.strip.downcase }
28
29 validates :email, presence: true,
30 uniqueness: { case_sensitive: false },
31 format: { with: URI::MailTo::EMAIL_REGEXP }
32 validates :password, length: { minimum: 8 }, if: -> { password.present? }
33 validates :role, presence: true
34
35 # Helper method to check if user has any admin access
36 def admin_or_above?
37 admin? || super_admin? || owner?
38 end
39
40 # Helper method to check if user has super admin or owner access
41 def super_admin_or_above?
42 super_admin? || owner?
43 end
44 end
45RUBY
46
47file 'app/models/session.rb', <<~RUBY, force: true
48 class Session < ApplicationRecord
49 belongs_to :user
50
51 before_create :generate_token
52
53 validates :token, presence: true, uniqueness: true
54
55 private
56
57 def generate_token
58 self.token = SecureRandom.urlsafe_base64(32)
59 end
60 end
61RUBY
62
63say ' Creating Current model...', :cyan
64file 'app/models/current.rb', <<~RUBY
65 class Current < ActiveSupport::CurrentAttributes
66 attribute :session, :user
67
68 delegate :user, to: :session, allow_nil: true
69 end
70RUBY
71
72say ' Creating Authentication concern...', :cyan
73file 'app/controllers/concerns/authentication.rb', <<~RUBY
74 module Authentication
75 extend ActiveSupport::Concern
76
77 included do
78 before_action :authenticate
79 helper_method :signed_in?, :current_user
80 end
81
82 private
83
84 def authenticate
85 if (session_record = find_session_by_cookie)
86 Current.session = session_record
87 end
88 end
89
90 def require_authentication
91 redirect_to sign_in_path, alert: 'Please sign in to continue.' unless signed_in?
92 end
93
94 def require_admin
95 require_authentication
96 return if current_user&.admin_or_above?
97
98 redirect_to root_path, alert: 'You are not authorized to access this page.'
99 end
100
101 def require_super_admin
102 require_authentication
103 return if current_user&.super_admin?
104
105 redirect_to root_path, alert: 'You are not authorized to access this page.'
106 end
107
108 def signed_in?
109 Current.session.present?
110 end
111
112 def current_user
113 Current.user
114 end
115
116 def find_session_by_cookie
117 Session.find_by(token: cookies.signed[:session_token])
118 end
119
120 def start_session(user)
121 session_record = user.sessions.create!(
122 ip_address: request.remote_ip,
123 user_agent: request.user_agent
124 )
125 cookies.signed.permanent[:session_token] = {
126 value: session_record.token,
127 httponly: true,
128 same_site: :lax
129 }
130 Current.session = session_record
131 end
132
133 def end_session
134 Current.session&.destroy
135 cookies.delete(:session_token)
136 end
137 end
138RUBY
139
140say ' Adding to ApplicationController...', :cyan
141inject_into_class 'app/controllers/application_controller.rb', 'ApplicationController', <<~RUBY
142 include Authentication
143RUBY
144
145say ' Creating SessionsController...', :cyan
146file 'app/controllers/sessions_controller.rb', <<~RUBY
147 class SessionsController < ApplicationController
148 skip_before_action :authenticate, only: %i[new create]
149
150 def new
151 redirect_to root_path if signed_in?
152 end
153
154 def create
155 if (user = User.find_by(email: params[:email])&.authenticate(params[:password]))
156 start_session(user)
157 redirect_to root_path, notice: 'Signed in successfully.'
158 else
159 flash.now[:alert] = 'Invalid email or password.'
160 render :new, status: :unprocessable_entity
161 end
162 end
163
164 def destroy
165 end_session
166 redirect_to root_path, notice: 'Signed out successfully.'
167 end
168 end
169RUBY
170
171say ' Creating RegistrationsController...', :cyan
172file 'app/controllers/registrations_controller.rb', <<~RUBY
173 class RegistrationsController < ApplicationController
174 skip_before_action :authenticate, only: %i[new create]
175
176 def new
177 redirect_to root_path if signed_in?
178 @user = User.new
179 end
180
181 def create
182 @user = User.new(user_params)
183 if @user.save
184 start_session(@user)
185 redirect_to root_path, notice: 'Account created successfully.'
186 else
187 render :new, status: :unprocessable_entity
188 end
189 end
190
191 private
192
193 def user_params
194 params.require(:user).permit(:email, :password, :password_confirmation)
195 end
196 end
197RUBY
198
199say ' Creating sign in view...', :cyan
200file 'app/views/sessions/new.html.erb', <<~ERB
201 <div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
202 <div class="max-w-md w-full space-y-8">
203 <div>
204 <h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
205 Sign in to your account
206 </h2>
207 <p class="mt-2 text-center text-sm text-gray-600">
208 Or
209 <%= link_to 'create a new account', sign_up_path, class: 'font-medium text-canopy-green hover:text-fresh-leaf' %>
210 </p>
211 </div>
212
213 <%= form_with url: sign_in_path, class: 'mt-8 space-y-6' do |f| %>
214 <div class="rounded-md shadow-sm -space-y-px">
215 <div>
216 <%= f.label :email, class: 'sr-only' %>
217 <%= f.email_field :email, required: true, autofocus: true, autocomplete: 'email',
218 placeholder: 'Email address',
219 class: 'appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-canopy-green focus:border-canopy-green focus:z-10 sm:text-sm' %>
220 </div>
221 <div>
222 <%= f.label :password, class: 'sr-only' %>
223 <%= f.password_field :password, required: true, autocomplete: 'current-password',
224 placeholder: 'Password',
225 class: 'appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-canopy-green focus:border-canopy-green focus:z-10 sm:text-sm' %>
226 </div>
227 </div>
228
229 <div>
230 <%= f.submit 'Sign in',
231 class: 'group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-canopy-green hover:bg-bamboo-shadow focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-canopy-green cursor-pointer' %>
232 </div>
233 <% end %>
234 </div>
235 </div>
236ERB
237
238say ' Creating sign up view...', :cyan
239file 'app/views/registrations/new.html.erb', <<~ERB
240 <div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
241 <div class="max-w-md w-full space-y-8">
242 <div>
243 <h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
244 Create your account
245 </h2>
246 <p class="mt-2 text-center text-sm text-gray-600">
247 Already have an account?
248 <%= link_to 'Sign in', sign_in_path, class: 'font-medium text-canopy-green hover:text-fresh-leaf' %>
249 </p>
250 </div>
251
252 <%= form_with model: @user, url: sign_up_path, class: 'mt-8 space-y-6' do |f| %>
253 <% if @user.errors.any? %>
254 <div class="rounded-md bg-red-50 p-4">
255 <div class="text-sm text-red-700">
256 <ul class="list-disc pl-5 space-y-1">
257 <% @user.errors.full_messages.each do |message| %>
258 <li><%= message %></li>
259 <% end %>
260 </ul>
261 </div>
262 </div>
263 <% end %>
264
265 <div class="rounded-md shadow-sm -space-y-px">
266 <div>
267 <%= f.label :email, class: 'sr-only' %>
268 <%= f.email_field :email, required: true, autofocus: true, autocomplete: 'email',
269 placeholder: 'Email address',
270 class: 'appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-canopy-green focus:border-canopy-green focus:z-10 sm:text-sm' %>
271 </div>
272 <div>
273 <%= f.label :password, class: 'sr-only' %>
274 <%= f.password_field :password, required: true, autocomplete: 'new-password',
275 placeholder: 'Password (min 8 characters)',
276 class: 'appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-canopy-green focus:border-canopy-green focus:z-10 sm:text-sm' %>
277 </div>
278 <div>
279 <%= f.label :password_confirmation, class: 'sr-only' %>
280 <%= f.password_field :password_confirmation, required: true, autocomplete: 'new-password',
281 placeholder: 'Confirm password',
282 class: 'appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-canopy-green focus:border-canopy-green focus:z-10 sm:text-sm' %>
283 </div>
284 </div>
285
286 <div>
287 <%= f.submit 'Create account',
288 class: 'group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-canopy-green hover:bg-bamboo-shadow focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-canopy-green cursor-pointer' %>
289 </div>
290 <% end %>
291 </div>
292 </div>
293ERB
294
295say ' Adding routes...', :cyan
296route <<~RUBY
297 # Authentication
298 get 'sign_in', to: 'sessions#new'
299 post 'sign_in', to: 'sessions#create'
300 delete 'sign_out', to: 'sessions#destroy'
301 get 'sign_up', to: 'registrations#new'
302 post 'sign_up', to: 'registrations#create'
303RUBY
304
305say ' Creating Admin namespace...', :cyan
306file 'app/controllers/admin/application_controller.rb', <<~RUBY
307 # frozen_string_literal: true
308
309 module Admin
310 class ApplicationController < ::ApplicationController
311 before_action :require_admin
312
313 def index
314 @current_user = current_user
315 end
316
317 private
318
319 def require_admin
320 unless current_user&.admin_or_above?
321 redirect_to root_path, alert: 'You are not authorized to access this area.'
322 end
323 end
324 end
325 end
326RUBY
327
328file 'app/controllers/admin/users_controller.rb', <<~RUBY
329 # frozen_string_literal: true
330
331 module Admin
332 class UsersController < Admin::ApplicationController
333 before_action :set_user, only: %i[show edit update destroy]
334
335 def index
336 @users = User.all
337 end
338
339 def show; end
340
341 def edit; end
342
343 def update
344 if @user.update(user_params)
345 redirect_to admin_user_path(@user), notice: 'User updated successfully.'
346 else
347 render :edit, status: :unprocessable_entity
348 end
349 end
350
351 def destroy
352 @user.destroy
353 redirect_to admin_users_path, notice: 'User deleted successfully.'
354 end
355
356 private
357
358 def set_user
359 @user = User.find(params[:id])
360 end
361
362 def user_params
363 params.require(:user).permit(:email, :role)
364 end
365 end
366 end
367RUBY
368
369say 'Custom authentication setup complete!', :green