feat: add user authentication with devise

- Integrate Devise for user authentication
- Create User model and necessary views
- Implement email confirmation and password reset functionality
- Add routing for user sign-up and login
- Customize error messages display

This commit initiates user authentication in the application
using the Devise gem. It includes user registration, login,
password reset, and email confirmation features. Additionally,
the necessary views and mailer templates are included to
support these functionalities. This enhances the security
and usability of the application by allowing users to create
accounts and manage their passwords effectively.
This commit is contained in:
songtianlun 2025-02-11 14:45:23 +08:00
parent 92ec81b152
commit 1545b33539
24 changed files with 370 additions and 12 deletions

View File

@ -65,7 +65,7 @@ gem "image_processing", "~> 1.13"
# gem "ruby-vips", "~> 2.2"
gem "mini_magick", "~> 4.13.2"
gem 'devise', '~> 4.9'
gem "devise", "~> 4.9"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem

6
app/models/user.rb Normal file
View File

@ -0,0 +1,6 @@
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
end

View File

@ -0,0 +1,16 @@
<h2>Resend confirmation instructions</h2>
<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %>
</div>
<div class="actions">
<%= f.submit "Resend confirmation instructions" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@ -0,0 +1,5 @@
<p>Welcome <%= @email %>!</p>
<p>You can confirm your account email through the link below:</p>
<p><%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p>

View File

@ -0,0 +1,7 @@
<p>Hello <%= @email %>!</p>
<% if @resource.try(:unconfirmed_email?) %>
<p>We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.</p>
<% else %>
<p>We're contacting you to notify you that your email has been changed to <%= @resource.email %>.</p>
<% end %>

View File

@ -0,0 +1,3 @@
<p>Hello <%= @resource.email %>!</p>
<p>We're contacting you to notify you that your password has been changed.</p>

View File

@ -0,0 +1,8 @@
<p>Hello <%= @resource.email %>!</p>
<p>Someone has requested a link to change your password. You can do this through the link below.</p>
<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
<p>If you didn't request this, please ignore this email.</p>
<p>Your password won't change until you access the link above and create a new one.</p>

View File

@ -0,0 +1,7 @@
<p>Hello <%= @resource.email %>!</p>
<p>Your account has been locked due to an excessive number of unsuccessful sign in attempts.</p>
<p>Click the link below to unlock your account:</p>
<p><%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %></p>

View File

@ -0,0 +1,25 @@
<h2>Change your password</h2>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<%= f.hidden_field :reset_password_token %>
<div class="field">
<%= f.label :password, "New password" %><br />
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em><br />
<% end %>
<%= f.password_field :password, autofocus: true, autocomplete: "new-password" %>
</div>
<div class="field">
<%= f.label :password_confirmation, "Confirm new password" %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<div class="actions">
<%= f.submit "Change my password" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@ -0,0 +1,16 @@
<h2>Forgot your password?</h2>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="actions">
<%= f.submit "Send me reset password instructions" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@ -0,0 +1,43 @@
<h2>Edit <%= resource_name.to_s.humanize %></h2>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
<div>Currently waiting confirmation for: <%= resource.unconfirmed_email %></div>
<% end %>
<div class="field">
<%= f.label :password %> <i>(leave blank if you don't want to change it)</i><br />
<%= f.password_field :password, autocomplete: "new-password" %>
<% if @minimum_password_length %>
<br />
<em><%= @minimum_password_length %> characters minimum</em>
<% end %>
</div>
<div class="field">
<%= f.label :password_confirmation %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<div class="field">
<%= f.label :current_password %> <i>(we need your current password to confirm your changes)</i><br />
<%= f.password_field :current_password, autocomplete: "current-password" %>
</div>
<div class="actions">
<%= f.submit "Update" %>
</div>
<% end %>
<h3>Cancel my account</h3>
<div>Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" }, method: :delete %></div>
<%= link_to "Back", :back %>

View File

@ -0,0 +1,29 @@
<h2>Sign up</h2>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="field">
<%= f.label :password %>
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em>
<% end %><br />
<%= f.password_field :password, autocomplete: "new-password" %>
</div>
<div class="field">
<%= f.label :password_confirmation %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<div class="actions">
<%= f.submit "Sign up" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@ -0,0 +1,26 @@
<h2>Log in</h2>
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="field">
<%= f.label :password %><br />
<%= f.password_field :password, autocomplete: "current-password" %>
</div>
<% if devise_mapping.rememberable? %>
<div class="field">
<%= f.check_box :remember_me %>
<%= f.label :remember_me %>
</div>
<% end %>
<div class="actions">
<%= f.submit "Log in" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@ -0,0 +1,15 @@
<% if resource.errors.any? %>
<div id="error_explanation" data-turbo-cache="false">
<h2>
<%= I18n.t("errors.messages.not_saved",
count: resource.errors.count,
resource: resource.class.model_name.human.downcase)
%>
</h2>
<ul>
<% resource.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

View File

@ -0,0 +1,25 @@
<%- if controller_name != 'sessions' %>
<%= link_to "Log in", new_session_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to "Sign up", new_registration_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to "Forgot your password?", new_password_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
<%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %><br />
<% end %>
<%- if devise_mapping.omniauthable? %>
<%- resource_class.omniauth_providers.each do |provider| %>
<%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %><br />
<% end %>
<% end %>

View File

@ -0,0 +1,16 @@
<h2>Resend unlock instructions</h2>
<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="actions">
<%= f.submit "Resend unlock instructions" %>
</div>
<% end %>
<%= render "devise/shared/links" %>

View File

@ -69,7 +69,27 @@
</div>
<!-- 主要内容 -->
<main class="pt-16">
<main class="pt-16 relative">
<div class="toast toast-top toast-end z-[100] fixed">
<% if notice %>
<div class="alert alert-success shadow-lg">
<div>
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span><%= notice %></span>
</div>
</div>
<% end %>
<% if alert %>
<div class="alert alert-error shadow-lg">
<div>
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span><%= alert %></span>
</div>
</div>
<% end %>
</div>
<%= yield %>
</main>

View File

@ -14,7 +14,7 @@ Devise.setup do |config|
# confirmation, reset password and unlock tokens in the database.
# Devise will use the `secret_key_base` as its `secret_key`
# by default. You can change it below and use your own secret key.
# config.secret_key = '8e01592add1deb098d03551e0d56b27cf262887b42ba72306fb604d7fb02a96cd2b9708262fffe4df924c3a8feb480e79a37709685346aadfc3e56efb4c49968'
# config.secret_key = '81c9caf47bc0267e289d8584e632053eb7de20e1cc1f2d64ce5ba081229caf1117826d4dac2c5ca60471871dc54fbd731f57f2b843246c9fbc83ed3e427b3070'
# ==> Controller configuration
# Configure the parent class to the devise controllers.
@ -126,7 +126,7 @@ Devise.setup do |config|
config.stretches = Rails.env.test? ? 1 : 12
# Set up a pepper to generate the hashed password.
# config.pepper = '8c7d54d89c6dcb3a9835bd41bcd8b907633b8c307849b255af68aefa4815838938c646722094a0b9b5c472417854bca020a84e291c4e00c1d404ce48eaf5e4e0'
# config.pepper = 'b06ea5045bb70198284af2b53bb9421b1ba33ba75f57972960c79e50f1d8018b36a2d11c59a9da2b135a7775077da7ce29f23099ded2070c3c7af505253547ab'
# Send a notification to the original email when the user's email is changed.
# config.send_email_changed_notification = false

View File

@ -1,6 +1,7 @@
require "sidekiq/web"
Rails.application.routes.draw do
devise_for :users
root "home#index"
resources :cities, only: [ :index, :show ] do

View File

@ -0,0 +1,47 @@
# frozen_string_literal: true
class DeviseCreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
## Database authenticatable
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""
## Recoverable
t.string :reset_password_token
t.datetime :reset_password_sent_at
## Rememberable
t.datetime :remember_created_at
## Trackable
t.integer :sign_in_count, default: 0, null: false
t.datetime :current_sign_in_at
t.datetime :last_sign_in_at
t.string :current_sign_in_ip
t.string :last_sign_in_ip
## Confirmable
t.string :confirmation_token
t.datetime :confirmed_at
t.datetime :confirmation_sent_at
t.string :unconfirmed_email # Only if using reconfirmable
## Lockable
t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
t.string :unlock_token # Only if unlock strategy is :email or :both
t.datetime :locked_at
## Admin
t.boolean :admin, default: false
t.timestamps null: false
end
add_index :users, :email, unique: true
add_index :users, :reset_password_token, unique: true
# add_index :users, :confirmation_token, unique: true
# add_index :users, :unlock_token, unique: true
end
end

35
db/schema.rb generated
View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_02_08_052634) do
ActiveRecord::Schema[8.0].define(version: 2025_02_11_035423) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -125,7 +125,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_08_052634) do
t.string "country_code"
t.boolean "flag", default: true
t.string "wiki_data_id"
t.integer "state_id"
t.bigint "state_id"
t.index ["country_id"], name: "index_cities_on_country_id"
t.index ["slug"], name: "index_cities_on_slug", unique: true
t.index ["state_id"], name: "index_cities_on_state_id"
@ -158,7 +158,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_08_052634) do
t.string "emoji_u"
t.boolean "flag", default: true
t.string "wiki_data_id"
t.integer "subregion_id"
t.bigint "subregion_id"
t.index ["code"], name: "index_countries_on_code", unique: true
t.index ["region_id"], name: "index_countries_on_region_id"
t.index ["slug"], name: "index_countries_on_slug", unique: true
@ -192,7 +192,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_08_052634) do
create_table "states", force: :cascade do |t|
t.string "name"
t.string "code"
t.integer "country_id"
t.bigint "country_id"
t.string "country_code"
t.string "fips_code"
t.string "iso2"
@ -211,7 +211,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_08_052634) do
create_table "subregions", force: :cascade do |t|
t.string "name", null: false
t.text "translations"
t.integer "region_id", null: false
t.bigint "region_id", null: false
t.boolean "flag", default: true
t.string "wiki_data_id"
t.datetime "created_at", null: false
@ -219,6 +219,31 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_08_052634) do
t.index ["region_id"], name: "index_subregions_on_region_id"
end
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
t.string "reset_password_token"
t.datetime "reset_password_sent_at"
t.datetime "remember_created_at"
t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at"
t.datetime "last_sign_in_at"
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
t.string "confirmation_token"
t.datetime "confirmed_at"
t.datetime "confirmation_sent_at"
t.string "unconfirmed_email"
t.integer "failed_attempts", default: 0, null: false
t.string "unlock_token"
t.datetime "locked_at"
t.boolean "admin", default: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
create_table "weather_arts", force: :cascade do |t|
t.integer "city_id", null: false
t.date "weather_date"

View File

@ -167,15 +167,15 @@ namespace :geo do
state = State.find_by(code: data["state_code"])
city = City.find_or_create_by!(name: data["name"]) do |c|
c.country_id = country.id
c.country_id = country.id,
c.latitude = data["latitude"],
c.longitude = data["longitude"]
end
puts "Syncing City[#{count}/#{sum}] [#{data["name"]}] Country:[#{country&.name}] "
count += 1
city.update!(
latitude: data["latitude"],
longitude: data["longitude"],
flag: data["flag"] || true,
wiki_data_id: data["wikiDataId"]
)

11
test/fixtures/users.yml vendored Normal file
View File

@ -0,0 +1,11 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# This model initially had no columns defined. If you add columns to the
# model remove the "{}" from the fixture names and add the columns immediately
# below each fixture, per the syntax in the comments below
#
#one: {}
# column: value
#
#two: {}
# column: value

7
test/models/user_test.rb Normal file
View File

@ -0,0 +1,7 @@
require "test_helper"
class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end