diff --git a/Gemfile b/Gemfile index 19338f5..9d50ac8 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,7 @@ gem "tailwindcss-rails" gem "jbuilder" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] @@ -66,3 +66,7 @@ group :test do gem "capybara" gem "selenium-webdriver" end + +gem "authentication-zero", "~> 4.0" + +gem "letter_opener", "~> 1.10", group: :development diff --git a/Gemfile.lock b/Gemfile.lock index c2fdecb..8cab2b2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -78,7 +78,9 @@ GEM addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) + authentication-zero (4.0.3) base64 (0.3.0) + bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) bigdecimal (4.1.2) bindex (0.8.1) @@ -99,6 +101,8 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + childprocess (5.1.0) + logger (~> 1.5) concurrent-ruby (1.3.6) connection_pool (3.0.2) crass (1.0.6) @@ -155,6 +159,12 @@ GEM thor (~> 1.3) zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.5) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) lint_roller (1.1.0) logger (1.7.0) loofah (2.25.1) @@ -391,6 +401,8 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + authentication-zero (~> 4.0) + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit @@ -400,6 +412,7 @@ DEPENDENCIES importmap-rails jbuilder kamal + letter_opener (~> 1.10) propshaft puma (>= 5.0) rails (~> 8.1.3) @@ -431,7 +444,9 @@ CHECKSUMS activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + authentication-zero (4.0.3) sha256=f005531be39355e3c85250759f28a62109a2b0f551cb3fda4ff046b590b2db4a base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e @@ -441,6 +456,7 @@ CHECKSUMS bundler (4.0.11) sha256=5bcec0fb78302e48d02ee46f10ee6e6942be647ba5b44a6d1ddfda9a240ce785 bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d @@ -469,6 +485,8 @@ CHECKSUMS json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59 kamal (2.11.0) sha256=1408864425e0dec7e0a14d712a3b13f614e9f3a425b7661d3f9d287a51d7dd75 language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + launchy (3.1.1) sha256=72b847b5cc961589dde2c395af0108c86ff0119f42d4648d25b5440ebb10059e + letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 diff --git a/app/assets/images/icon.svg b/app/assets/images/icon.svg new file mode 100644 index 0000000..dd305a5 --- /dev/null +++ b/app/assets/images/icon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333..9b33eca 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -8,3 +8,29 @@ * * Consider organizing styles into separate files for maintainability. */ + +@media (min-width: 768px) { + /* 1. Die Sidebar verkleinert sich flüssig auf 64px */ + #desktop-sidebar-toggle:checked ~ aside { + width: 4rem !important; /* md:w-16 */ + } + + /* 2. Fehlerbehebung: Der rechte Inhaltsbereich wird beim Einklappen unsichtbar */ + #desktop-sidebar-toggle:checked ~ aside .footer-collapse-content { + opacity: 0 !important; + visibility: hidden !important; + transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out; + } + + /* 3. Die Standard-Navigations-Texte oben schrumpfen ebenfalls */ + #desktop-sidebar-toggle:checked ~ aside .collapse-text { + max-width: 0 !important; + opacity: 0 !important; + margin-left: 0 !important; + } + + /* 4. Der Hauptinhalt rückt nach links nach */ + #desktop-sidebar-toggle:checked ~ .main-content { + padding-left: 4rem !important; + } +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c353756..4432ef3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,6 +2,20 @@ class ApplicationController < ActionController::Base # 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 + before_action :set_current_request_details + before_action :authenticate + + private + def authenticate + if session_record = Session.find_by_id(cookies.signed[:session_token]) + Current.session = session_record + else + redirect_to sign_in_path + end + end + + def set_current_request_details + Current.user_agent = request.user_agent + Current.ip_address = request.ip + end end diff --git a/app/controllers/authentications/events_controller.rb b/app/controllers/authentications/events_controller.rb new file mode 100644 index 0000000..a3cce73 --- /dev/null +++ b/app/controllers/authentications/events_controller.rb @@ -0,0 +1,5 @@ +class Authentications::EventsController < ApplicationController + def index + @events = Current.user.events.order(created_at: :desc).limit(30) + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 0000000..95f2992 --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,4 @@ +class HomeController < ApplicationController + def index + end +end diff --git a/app/controllers/identity/email_verifications_controller.rb b/app/controllers/identity/email_verifications_controller.rb new file mode 100644 index 0000000..5c48dc1 --- /dev/null +++ b/app/controllers/identity/email_verifications_controller.rb @@ -0,0 +1,26 @@ +class Identity::EmailVerificationsController < ApplicationController + skip_before_action :authenticate, only: :show + + before_action :set_user, only: :show + + def show + @user.update! verified: true + redirect_to root_path, notice: "Thank you for verifying your email address" + end + + def create + send_email_verification + redirect_to root_path, notice: "We sent a verification email to your email address" + end + + private + def set_user + @user = User.find_by_token_for!(:email_verification, params[:sid]) + rescue StandardError + redirect_to edit_identity_email_path, alert: "That email verification link is invalid" + end + + def send_email_verification + UserMailer.with(user: Current.user).email_verification.deliver_later + end +end diff --git a/app/controllers/identity/emails_controller.rb b/app/controllers/identity/emails_controller.rb new file mode 100644 index 0000000..407e388 --- /dev/null +++ b/app/controllers/identity/emails_controller.rb @@ -0,0 +1,36 @@ +class Identity::EmailsController < ApplicationController + before_action :set_user + + def edit + end + + def update + if @user.update(user_params) + redirect_to_root + else + render :edit, status: :unprocessable_entity + end + end + + private + def set_user + @user = Current.user + end + + def user_params + params.permit(:email, :password_challenge).with_defaults(password_challenge: "") + end + + def redirect_to_root + if @user.email_previously_changed? + resend_email_verification + redirect_to root_path, notice: "Your email has been changed" + else + redirect_to root_path + end + end + + def resend_email_verification + UserMailer.with(user: @user).email_verification.deliver_later + end +end diff --git a/app/controllers/identity/password_resets_controller.rb b/app/controllers/identity/password_resets_controller.rb new file mode 100644 index 0000000..142d668 --- /dev/null +++ b/app/controllers/identity/password_resets_controller.rb @@ -0,0 +1,45 @@ +class Identity::PasswordResetsController < ApplicationController + skip_before_action :authenticate + + before_action :set_user, only: %i[ edit update ] + + layout "auth" + + def new + end + + def edit + end + + def create + if @user = User.find_by(email: params[:email], verified: true) + send_password_reset_email + redirect_to sign_in_path, notice: "Check your email for reset instructions" + else + redirect_to new_identity_password_reset_path, alert: "You can't reset your password until you verify your email" + end + end + + def update + if @user.update(user_params) + redirect_to sign_in_path, notice: "Your password was reset successfully. Please sign in" + else + render :edit, status: :unprocessable_entity + end + end + + private + def set_user + @user = User.find_by_token_for!(:password_reset, params[:sid]) + rescue StandardError + redirect_to new_identity_password_reset_path, alert: "That password reset link is invalid" + end + + def user_params + params.permit(:password, :password_confirmation) + end + + def send_password_reset_email + UserMailer.with(user: @user).password_reset.deliver_later + end +end diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb new file mode 100644 index 0000000..1b4a28a --- /dev/null +++ b/app/controllers/invitations_controller.rb @@ -0,0 +1,25 @@ +class InvitationsController < ApplicationController + def new + @user = User.new + end + + def create + @user = User.create_with(user_params).find_or_initialize_by(email: params[:email]) + + if @user.save + send_invitation_instructions + redirect_to new_invitation_path, notice: "An invitation email has been sent to #{@user.email}" + else + render :new, status: :unprocessable_entity + end + end + + private + def user_params + params.permit(:email).merge(password: SecureRandom.base58, verified: true) + end + + def send_invitation_instructions + UserMailer.with(user: @user).invitation_instructions.deliver_later + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 0000000..1956a8b --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,23 @@ +class PasswordsController < ApplicationController + before_action :set_user + + def edit + end + + def update + if @user.update(user_params) + redirect_to root_path, notice: "Your password has been changed" + else + render :edit, status: :unprocessable_entity + end + end + + private + def set_user + @user = Current.user + end + + def user_params + params.permit(:password, :password_confirmation, :password_challenge).with_defaults(password_challenge: "") + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 0000000..4b5e41a --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,32 @@ +class RegistrationsController < ApplicationController + skip_before_action :authenticate + + layout "auth" + + def new + @user = User.new + end + + def create + @user = User.new(user_params) + + if @user.save + session_record = @user.sessions.create! + cookies.signed.permanent[:session_token] = { value: session_record.id, httponly: true } + + send_email_verification + redirect_to root_path, notice: "Welcome! You have signed up successfully" + else + render :new, status: :unprocessable_entity + end + end + + private + def user_params + params.permit(:email, :password, :password_confirmation) + end + + def send_email_verification + UserMailer.with(user: @user).email_verification.deliver_later + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..1b3c8bd --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,34 @@ +class SessionsController < ApplicationController + skip_before_action :authenticate, only: %i[ new create ] + + before_action :set_session, only: :destroy + + layout "auth", only: %i[ new create ] + + def index + @sessions = Current.user.sessions.order(created_at: :desc) + end + + def new + end + + def create + if user = User.authenticate_by(email: params[:email], password: params[:password]) + @session = user.sessions.create! + cookies.signed.permanent[:session_token] = { value: @session.id, httponly: true } + + redirect_to root_path, notice: "Signed in successfully" + else + redirect_to sign_in_path(email_hint: params[:email]), alert: "That email or password is incorrect" + end + end + + def destroy + @session.destroy; redirect_to(sessions_path, notice: "That session has been logged out") + end + + private + def set_session + @session = Current.user.sessions.find(params[:id]) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..efe86f9 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,36 @@ module ApplicationHelper + def nav_link_class(target_controller) + base_classes = "flex items-center gap-3 px-3 py-2.5 text-sm font-medium rounded-lg transition-colors duration-200" + + if controller_name == target_controller + "#{base_classes} bg-blue-50 text-blue-600 font-semibold" + else + "#{base_classes} text-gray-700 hover:bg-gray-50" + end + end + + def parse_user_agent(user_agent_string) + ua = user_agent_string.to_s.downcase + + # Browser-Erkennung via Case-Regex (nutzt === im Hintergrund) + browser = case ua + when /firefox/ then "Firefox" + when /chrome/ then "Chrome" + when /safari/ then "Safari" + when /edge/ then "Edge" + else "Unbekannter Browser" + end + + # Betriebssystem-Erkennung via Case-Regex + os = case ua + when /windows/ then "Windows" + when /macintosh|mac os/ then "macOS" + when /iphone/ then "iPhone" + when /android/ then "Android" + when /linux/ then "Linux" + else "Betriebssystem" + end + + "#{browser} auf #{os}" + end end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..67434fc --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,22 @@ +class UserMailer < ApplicationMailer + def password_reset + @user = params[:user] + @signed_id = @user.generate_token_for(:password_reset) + + mail to: @user.email, subject: "Reset your password" + end + + def email_verification + @user = params[:user] + @signed_id = @user.generate_token_for(:email_verification) + + mail to: @user.email, subject: "Verify your email" + end + + def invitation_instructions + @user = params[:user] + @signed_id = @user.generate_token_for(:password_reset) + + mail to: @user.email, subject: "Invitation instructions" + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 0000000..0d0863f --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,6 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :session + attribute :user_agent, :ip_address + + delegate :user, to: :session, allow_nil: true +end diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 0000000..8bb724a --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,8 @@ +class Event < ApplicationRecord + belongs_to :user + + before_create do + self.user_agent = Current.user_agent + self.ip_address = Current.ip_address + end +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 0000000..19081a0 --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,11 @@ +class Session < ApplicationRecord + belongs_to :user + + before_create do + self.user_agent = Current.user_agent + self.ip_address = Current.ip_address + end + + after_create { user.events.create! action: "signed_in" } + after_destroy { user.events.create! action: "signed_out" } +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..805275e --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,45 @@ +class User < ApplicationRecord + @@min_length_password = 12 + has_secure_password + + generates_token_for :email_verification, expires_in: 2.days do + email + end + + generates_token_for :password_reset, expires_in: 20.minutes do + password_salt.last(10) + end + + + has_many :sessions, dependent: :destroy + has_many :events, dependent: :destroy + + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, allow_nil: true, length: { minimum: @@min_length_password } + + normalizes :email, with: -> { _1.strip.downcase } + + before_validation if: :email_changed?, on: :update do + self.verified = false + end + + after_update if: :password_digest_previously_changed? do + sessions.where.not(id: Current.session).delete_all + end + + after_update if: :email_previously_changed? do + events.create! action: "email_verification_requested" + end + + after_update if: :password_digest_previously_changed? do + events.create! action: "password_changed" + end + + after_update if: [ :verified_previously_changed?, :verified? ] do + events.create! action: "email_verified" + end + + def self.min_length_password + @@min_length_password + end +end diff --git a/app/views/authentications/events/index.html.erb b/app/views/authentications/events/index.html.erb new file mode 100644 index 0000000..8f30cc1 --- /dev/null +++ b/app/views/authentications/events/index.html.erb @@ -0,0 +1,180 @@ + + +<% content_for :title, "Konto-Aktivitäten & Logs" %> + +
Chronologische Übersicht der letzten 30 kritischen und erfolgreichen Ereignisse deines Kontos.
++ <% case event.action %> + <% when "login_success" %> + Erfolgreiche Anmeldung im System + <% when "password_changed" %> + Passwort erfolgreich geändert + <% when "two_factor_activated" %> + Zwei-Faktor-Authentifizierung aktiviert + <% when "login_failed" %> + Fehlgeschlagener Anmeldeversuch + <% else %> + <%= event.action.humanize %> + <% end %> +
+ + ++ IP: <%= event.ip_address.presence || "Unbekannt" %> + <% if event.respond_to?(:user_agent) && event.user_agent.present? %> + • <%= parse_user_agent(event.user_agent) %> + <% end %> +
+Bisher wurden keine Aktivitäten aufgezeichnet.
+<%= notice %>
+ +Signed as <%= Current.user.email %>
+ +<%= alert %>
+ +<% if Current.user.verified? %> +We sent a verification email to the address below. Check that email and follow those instructions to confirm it's your email address.
+<%= button_to "Re-send verification email", identity_email_verification_path %>
+<% end %> + +<%= form_with(url: identity_email_path, method: :patch) do |form| %> + <% if @user.errors.any? %> ++ Wählen Sie ein neues, sicheres Passwort für Ihr Benutzerkonto. +
+Mindestens <%= User.min_length_password %> Zeichen lang.
++ Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Link zum Zurücksetzen. +
+<%= notice %>
+ +Deine registrierten Profildaten in Vault171.
+Hier kannst du dein aktuelles Passwort aktualisieren.
++ Oder <%= link_to "mit bestehendem Konto anmelden", sign_in_path, class: "font-medium text-blue-600 hover:text-blue-500 transition-colors" %> +
+Das Passwort muss mindestens 8 Zeichen lang sein.
+Hier siehst du alle Browser und Geräte, mit denen du aktuell in Vault171 angemeldet bist.
+IP: <%= session.ip_address || "Unbekannt" %>
++ Letzte Aktivität: <%= l(session.updated_at, format: :short) %> +
++ oder <%= link_to "ein neues Konto erstellen", sign_up_path, class: "font-medium text-blue-600 hover:text-blue-500 transition" %> +
+Hey there,
+ +This is to confirm that <%= @user.email %> is the email you want to use on your account. If you ever lose your password, that's where we'll email a reset link.
+ +You must hit the link below to confirm that you received this email.
+ +<%= link_to "Yes, use this email for my account", identity_email_verification_url(sid: @signed_id) %>
+ +Have questions or need help? Just reply to this email and our support team will help you sort it out.
diff --git a/app/views/user_mailer/invitation_instructions.html.erb b/app/views/user_mailer/invitation_instructions.html.erb new file mode 100644 index 0000000..04cfa11 --- /dev/null +++ b/app/views/user_mailer/invitation_instructions.html.erb @@ -0,0 +1,11 @@ +Hey there,
+ +Someone has invited you to the application, you can accept it through the link below.
+ +<%= link_to "Accept invitation", edit_identity_password_reset_url(sid: @signed_id) %>
+ +If you don't want to accept the invitation, please ignore this email. Your account won't be created until you access the link above and set your password.
+ +Have questions or need help? Just reply to this email and our support team will help you sort it out.
diff --git a/app/views/user_mailer/password_reset.html.erb b/app/views/user_mailer/password_reset.html.erb new file mode 100644 index 0000000..c570a0a --- /dev/null +++ b/app/views/user_mailer/password_reset.html.erb @@ -0,0 +1,11 @@ +Hey there,
+ +Can't remember your password for <%= @user.email %>? That's OK, it happens. Just hit the link below to set a new one.
+ +<%= link_to "Reset my password", edit_identity_password_reset_url(sid: @signed_id) %>
+ +If you did not request a password reset you can safely ignore this email, it expires in 20 minutes. Only someone with access to this email account can reset your password.
+ +Have questions or need help? Just reply to this email and our support team will help you sort it out.
diff --git a/config/routes.rb b/config/routes.rb index 48254e8..72feb4c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,19 @@ Rails.application.routes.draw do + namespace :authentications do + resources :events, only: :index + end + resource :invitation, only: [ :new, :create ] + get "sign_in", to: "sessions#new" + post "sign_in", to: "sessions#create" + get "sign_up", to: "registrations#new" + post "sign_up", to: "registrations#create" + resources :sessions, only: [ :index, :show, :destroy ] + resource :password, only: [ :edit, :update ] + namespace :identity do + resource :email, only: [ :edit, :update ] + resource :email_verification, only: [ :show, :create ] + resource :password_reset, only: [ :new, :edit, :create, :update ] + end # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. @@ -10,5 +25,5 @@ Rails.application.routes.draw do # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker # Defines the root path route ("/") - # root "posts#index" + root "home#index" end diff --git a/db/migrate/20260520205214_create_users.rb b/db/migrate/20260520205214_create_users.rb new file mode 100644 index 0000000..de6393d --- /dev/null +++ b/db/migrate/20260520205214_create_users.rb @@ -0,0 +1,12 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email, null: false, index: { unique: true } + t.string :password_digest, null: false + + t.boolean :verified, null: false, default: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260520205215_create_sessions.rb b/db/migrate/20260520205215_create_sessions.rb new file mode 100644 index 0000000..216185e --- /dev/null +++ b/db/migrate/20260520205215_create_sessions.rb @@ -0,0 +1,11 @@ +class CreateSessions < ActiveRecord::Migration[8.1] + def change + create_table :sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :user_agent + t.string :ip_address + + t.timestamps + end + end +end diff --git a/db/migrate/20260520205436_create_events.rb b/db/migrate/20260520205436_create_events.rb new file mode 100644 index 0000000..a106871 --- /dev/null +++ b/db/migrate/20260520205436_create_events.rb @@ -0,0 +1,12 @@ +class CreateEvents < ActiveRecord::Migration[8.1] + def change + create_table :events do |t| + t.references :user, null: false, foreign_key: true + t.string :action, null: false + t.string :user_agent + t.string :ip_address + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..2e829d3 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,44 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 2026_05_20_205436) do + create_table "events", force: :cascade do |t| + t.string "action", null: false + t.datetime "created_at", null: false + t.string "ip_address" + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["user_id"], name: "index_events_on_user_id" + end + + create_table "sessions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "ip_address" + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email", null: false + t.string "password_digest", null: false + t.datetime "updated_at", null: false + t.boolean "verified", default: false, null: false + t.index ["email"], name: "index_users_on_email", unique: true + end + + add_foreign_key "events", "users" + add_foreign_key "sessions", "users" +end diff --git a/test/controllers/identity/email_verifications_controller_test.rb b/test/controllers/identity/email_verifications_controller_test.rb new file mode 100644 index 0000000..90d8513 --- /dev/null +++ b/test/controllers/identity/email_verifications_controller_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class Identity::EmailVerificationsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = sign_in_as(users(:lazaro_nixon)) + @user.update! verified: false + end + + test "should send a verification email" do + assert_enqueued_email_with UserMailer, :email_verification, params: { user: @user } do + post identity_email_verification_url + end + + assert_redirected_to root_url + end + + test "should verify email" do + sid = @user.generate_token_for(:email_verification) + + get identity_email_verification_url(sid: sid, email: @user.email) + assert_redirected_to root_url + end + + test "should not verify email with expired token" do + sid = @user.generate_token_for(:email_verification) + + travel 3.days + + get identity_email_verification_url(sid: sid, email: @user.email) + + assert_redirected_to edit_identity_email_url + assert_equal "That email verification link is invalid", flash[:alert] + end +end diff --git a/test/controllers/identity/emails_controller_test.rb b/test/controllers/identity/emails_controller_test.rb new file mode 100644 index 0000000..dc8ecc4 --- /dev/null +++ b/test/controllers/identity/emails_controller_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +class Identity::EmailsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = sign_in_as(users(:lazaro_nixon)) + end + + test "should get edit" do + get edit_identity_email_url + assert_response :success + end + + test "should update email" do + patch identity_email_url, params: { email: "new_email@hey.com", password_challenge: "Secret1*3*5*" } + assert_redirected_to root_url + end + + + test "should not update email with wrong password challenge" do + patch identity_email_url, params: { email: "new_email@hey.com", password_challenge: "SecretWrong1*3" } + + assert_response :unprocessable_entity + assert_select "li", /Password challenge is invalid/ + end +end diff --git a/test/controllers/identity/password_resets_controller_test.rb b/test/controllers/identity/password_resets_controller_test.rb new file mode 100644 index 0000000..5b807c5 --- /dev/null +++ b/test/controllers/identity/password_resets_controller_test.rb @@ -0,0 +1,65 @@ +require "test_helper" + +class Identity::PasswordResetsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:lazaro_nixon) + end + + test "should get new" do + get new_identity_password_reset_url + assert_response :success + end + + test "should get edit" do + sid = @user.generate_token_for(:password_reset) + + get edit_identity_password_reset_url(sid: sid) + assert_response :success + end + + test "should send a password reset email" do + assert_enqueued_email_with UserMailer, :password_reset, params: { user: @user } do + post identity_password_reset_url, params: { email: @user.email } + end + + assert_redirected_to sign_in_url + end + + test "should not send a password reset email to a nonexistent email" do + assert_no_enqueued_emails do + post identity_password_reset_url, params: { email: "invalid_email@hey.com" } + end + + assert_redirected_to new_identity_password_reset_url + assert_equal "You can't reset your password until you verify your email", flash[:alert] + end + + test "should not send a password reset email to a unverified email" do + @user.update! verified: false + + assert_no_enqueued_emails do + post identity_password_reset_url, params: { email: @user.email } + end + + assert_redirected_to new_identity_password_reset_url + assert_equal "You can't reset your password until you verify your email", flash[:alert] + end + + test "should update password" do + sid = @user.generate_token_for(:password_reset) + + patch identity_password_reset_url, params: { sid: sid, password: "Secret6*4*2*", password_confirmation: "Secret6*4*2*" } + assert_redirected_to sign_in_url + end + + test "should not update password with expired token" do + sid = @user.generate_token_for(:password_reset) + + travel 30.minutes + + patch identity_password_reset_url, params: { sid: sid, password: "Secret6*4*2*", password_confirmation: "Secret6*4*2*" } + + assert_redirected_to new_identity_password_reset_url + assert_equal "That password reset link is invalid", flash[:alert] + end +end diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb new file mode 100644 index 0000000..430f1a1 --- /dev/null +++ b/test/controllers/passwords_controller_test.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class PasswordsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = sign_in_as(users(:lazaro_nixon)) + end + + test "should get edit" do + get edit_password_url + assert_response :success + end + + test "should update password" do + patch password_url, params: { password_challenge: "Secret1*3*5*", password: "Secret6*4*2*", password_confirmation: "Secret6*4*2*" } + assert_redirected_to root_url + end + + test "should not update password with wrong password challenge" do + patch password_url, params: { password_challenge: "SecretWrong1*3", password: "Secret6*4*2*", password_confirmation: "Secret6*4*2*" } + + assert_response :unprocessable_entity + assert_select "li", /Password challenge is invalid/ + end +end diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb new file mode 100644 index 0000000..1636fd8 --- /dev/null +++ b/test/controllers/registrations_controller_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class RegistrationsControllerTest < ActionDispatch::IntegrationTest + test "should get new" do + get sign_up_url + assert_response :success + end + + test "should sign up" do + assert_difference("User.count") do + post sign_up_url, params: { email: "lazaronixon@hey.com", password: "Secret1*3*5*", password_confirmation: "Secret1*3*5*" } + end + + assert_redirected_to root_url + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 0000000..975c1ca --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:lazaro_nixon) + end + + test "should get index" do + sign_in_as @user + + get sessions_url + assert_response :success + end + + test "should get new" do + get sign_in_url + assert_response :success + end + + test "should sign in" do + post sign_in_url, params: { email: @user.email, password: "Secret1*3*5*" } + assert_redirected_to root_url + + get root_url + assert_response :success + end + + test "should not sign in with wrong credentials" do + post sign_in_url, params: { email: @user.email, password: "SecretWrong1*3" } + assert_redirected_to sign_in_url(email_hint: @user.email) + assert_equal "That email or password is incorrect", flash[:alert] + + get root_url + assert_redirected_to sign_in_url + end + + test "should sign out" do + sign_in_as @user + + delete session_url(@user.sessions.last) + assert_redirected_to sessions_url + + follow_redirect! + assert_redirected_to sign_in_url + end +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..4c5f5d7 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,6 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +lazaro_nixon: + email: lazaronixon@hotmail.com + password_digest: <%= BCrypt::Password.create("Secret1*3*5*") %> + verified: true diff --git a/test/mailers/user_mailer_test.rb b/test/mailers/user_mailer_test.rb new file mode 100644 index 0000000..e74b76e --- /dev/null +++ b/test/mailers/user_mailer_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class UserMailerTest < ActionMailer::TestCase + setup do + @user = users(:lazaro_nixon) + end + + test "password_reset" do + mail = UserMailer.with(user: @user).password_reset + assert_equal "Reset your password", mail.subject + assert_equal [@user.email], mail.to + end + + test "email_verification" do + mail = UserMailer.with(user: @user).email_verification + assert_equal "Verify your email", mail.subject + assert_equal [@user.email], mail.to + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..3631664 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,14 +2,15 @@ ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" -module ActiveSupport - class TestCase - # Run tests in parallel with specified workers - parallelize(workers: :number_of_processors) +class ActiveSupport::TestCase + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) - # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. - fixtures :all + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all - # Add more helper methods to be used by all tests here... + # Add more helper methods to be used by all tests here... + def sign_in_as(user) + post(sign_in_url, params: { email: user.email, password: "Secret1*3*5*" }); user end end