Compare commits

...

3 Commits

Author SHA1 Message Date
Trevor Vallender 2f940fab13 Add password reset functionality 2024-06-03 11:54:38 +01:00
Trevor Vallender f17b5046c4 First pass styling improvements 2024-05-30 17:08:51 +01:00
Trevor Vallender ac0f759a4a Add avatars to users 2024-05-30 15:30:25 +01:00
26 changed files with 250 additions and 15 deletions

View File

@ -20,11 +20,14 @@ gem "image_processing", "~> 1.2"
gem "solid_queue" gem "solid_queue"
gem "mission_control-jobs" gem "mission_control-jobs"
gem "active_storage_validations"
group :development, :test do group :development, :test do
gem "debug", platforms: %i[ mri windows ] gem "debug", platforms: %i[ mri windows ]
end end
group :development do group :development do
gem "letter_opener"
gem "rubocop-rails-omakase" gem "rubocop-rails-omakase"
gem "web-console" gem "web-console"
end end

View File

@ -50,6 +50,11 @@ GEM
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
active_storage_validations (1.1.4)
activejob (>= 5.2.0)
activemodel (>= 5.2.0)
activestorage (>= 5.2.0)
activesupport (>= 5.2.0)
activejob (7.1.3.3) activejob (7.1.3.3)
activesupport (= 7.1.3.3) activesupport (= 7.1.3.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
@ -94,6 +99,7 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
childprocess (5.0.0)
concurrent-ruby (1.2.3) concurrent-ruby (1.2.3)
connection_pool (2.4.1) connection_pool (2.4.1)
crass (1.0.6) crass (1.0.6)
@ -129,6 +135,11 @@ GEM
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
json (2.7.2) json (2.7.2)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
launchy (3.0.1)
addressable (~> 2.8)
childprocess (~> 5.0)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
loofah (2.22.0) loofah (2.22.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
@ -314,6 +325,7 @@ PLATFORMS
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
active_storage_validations
bcrypt (~> 3.1.7) bcrypt (~> 3.1.7)
bootsnap bootsnap
capybara capybara
@ -321,6 +333,7 @@ DEPENDENCIES
image_processing (~> 1.2) image_processing (~> 1.2)
importmap-rails importmap-rails
jbuilder jbuilder
letter_opener
mission_control-jobs mission_control-jobs
pg pg
propshaft propshaft

View File

@ -1,4 +1,10 @@
:root { :root {
--background-color: #333;
--main-background-color:
--header-color: #15345b;
--header-text-color: #fff;
--header-height: 200px;
--inset-bg-color: #eee; --inset-bg-color: #eee;
--border-radius: .5em; --border-radius: .5em;

View File

@ -32,7 +32,7 @@ form, fieldset {
font-size: .8em; font-size: .8em;
} }
fieldset, p, trix-editor { fieldset, p, trix-editor, hr {
grid-column: 1/3; grid-column: 1/3;
} }

View File

@ -4,14 +4,18 @@
body { body {
font-size: 1.1em; font-size: 1.1em;
margin: 0 auto; background-color: var(--background-color);
max-width: 80em; min-width: 30em;
padding: 1em;
} }
main { main {
background: linear-gradient(180deg, rgba(227,220,190,1) 0%, rgba(255,248,238,1) 100%);
max-width: 80vw;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 1em;
margin: 0 auto;
min-height: calc(100vh - var(--header-height));
} }
aside.flash { aside.flash {
@ -38,6 +42,35 @@ h1, h2 {
text-align: center; text-align: center;
} }
header {
background: linear-gradient(180deg, rgba(34,45,113,1) 0%, rgba(22,90,157,1) 100%);
position: relative;
padding: 0 50px;
margin-right: 2em;
height: var(--header-height);
}
/* Angled banner end */
header:before {
content: "";
width: 0;
height: 0;
border-top: calc(var(--header-height) / 2) solid transparent;
border-bottom: calc(var(--header-height) / 2) solid transparent;
border-right: calc(var(--header-height) / 2) solid var(--background-color);
position: absolute;
right:0;
}
header h1 {
padding: 1em 0;
}
header h1 a:link, header h1 a:visited {
color: var(--header-text-color);
text-decoration: none;
}
header nav { header nav {
ul { ul {
display: flex; display: flex;
@ -59,3 +92,7 @@ header nav {
color: var(--button-hover-text-color); color: var(--button-hover-text-color);
} }
} }
hr {
width: 100%;
}

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
class PasswordResetsController < ApplicationController
skip_before_action :authenticate
def new
reset_session
end
def create
user = User.find_by(username: params[:username])
if user
token = user.generate_token_for(:password_reset)
UserMailer.with(user: user, token: token).password_reset.deliver_later
redirect_to new_session_path, notice: t(".success") and return
end
redirect_to :root, notice: t(".error")
end
def edit
reset_session
@user = User.find_by(username: params[:id])
@token = params[:token]
unless @user == User.find_by_token_for(:password_reset, params[:token])
redirect_to :root, notice: t(".invalid_token") and return
end
end
def update
user = User.find_by(username: params[:id])
unless user == User.find_by_token_for(:password_reset, params[:token])
redirect_to :root, notice: t(".invalid_token") and return
end
if user.update(password: params[:password], password_confirmation: params[:password_confirmation])
redirect_to new_session_path, notice: t(".success")
else
redirect_to :root, notice: t(".error")
end
end
end

View File

@ -4,7 +4,7 @@ class TablesController < ApplicationController
before_action :set_table, only: [ :show, :edit, :update, :destroy ] before_action :set_table, only: [ :show, :edit, :update, :destroy ]
def index def index
@owned_tables = Current.user.owned_tables @tables = Current.user.tables
end end
def show def show

View File

@ -61,6 +61,7 @@ class UsersController < ApplicationController
:first_name, :first_name,
:last_name, :last_name,
:profile, :profile,
:avatar,
) )
end end

View File

@ -6,12 +6,12 @@ class TableInviteMailer < ApplicationMailer
def invite_user def invite_user
@table_invite = params[:table_invite] @table_invite = params[:table_invite]
mail(to: @table_invite.email, subject: "[Tabletop Companion] Invite to join a table") mail(to: @table_invite.email, subject: t(".invite_user.subject"))
end end
def invite_new_user def invite_new_user
@table_invite = params[:table_invite] @table_invite = params[:table_invite]
mail(to: @table_invite.email, subject: "[Tabletop Companion] Youve been invited to a game!") mail(to: @table_invite.email, subject: t(".invite_new_user.subject"))
end end
end end

View File

@ -7,12 +7,19 @@ class UserMailer < ApplicationMailer
@user = params[:user] @user = params[:user]
@token = params[:token] @token = params[:token]
mail(to: @user.email, subject: "[Tabletop Companion] Verify your email") mail(to: @user.email, subject: t(".email_verification.subject"))
end end
def email_verified def email_verified
@user = params[:user] @user = params[:user]
mail(to: @user.email, subject: "[Tabletop Companion] Your email has been verified") mail(to: @user.email, subject: t(".email_verified.subject"))
end
def password_reset
@user = params[:user]
@token = params[:token]
mail(to: @user.email, subject: t(".password_reset.subject"))
end end
end end

View File

@ -6,6 +6,11 @@ class User < ApplicationRecord
has_many :players, dependent: :destroy has_many :players, dependent: :destroy
has_many :tables, through: :players has_many :tables, through: :players
has_rich_text :profile has_rich_text :profile
has_one_attached :avatar do |attachable|
attachable.variant :standard, resize_to_limit: [ 100, 100 ], preprocessed: true
end
validates :avatar, content_type: /\Aimage\/.*\z/,
dimension: { width: { in: 10..1000 }, height: { in: 10..1000 } }
has_secure_password has_secure_password
generates_token_for :password_reset, expires_in: 4.hours do generates_token_for :password_reset, expires_in: 4.hours do

View File

@ -31,8 +31,8 @@
</ul> </ul>
</nav> </nav>
</header> </header>
<%= render partial: "shared/flash_messages" %>
<main> <main>
<%= render partial: "shared/flash_messages" %>
<%= yield(:submenu) if content_for?(:submenu) %> <%= yield(:submenu) if content_for?(:submenu) %>
<%= yield %> <%= yield %>
</main> </main>

View File

@ -0,0 +1,17 @@
<%= content_for :title, t(".reset_password") %>
<h2><%= t(".reset_password") %></h2>
<section class="inset">
<%= form_with url: password_reset_path(id: @user.username), method: :patch do |f| %>
<%= f.hidden_field :token, value: @token %>
<%= f.label :password %>
<%= f.password_field :password %>
<%= f.label :password_confirmation %>
<%= f.password_field :password_confirmation %>
<%= f.submit t(".reset_password_button") %>
<% end %>
</section>

View File

@ -0,0 +1,15 @@
<%= content_for :title, t(".reset_password") %>
<h2><%= t(".reset_password") %></h2>
<section class="inset">
<p><%= t(".forgotten_password_intro") %></p>
<%= form_with url: password_resets_path do |f| %>
<%= f.label :username %>
<%= f.text_field :username %>
<%= f.submit t(".reset_password_button") %>
<% end %>
</section>

View File

@ -14,3 +14,4 @@
<% end %> <% end %>
</section> </section>
<%= link_to(t(".forgot_password"), new_password_reset_path) %>

View File

@ -2,4 +2,7 @@
<div id="<%= dom_id(table) %>" class="table"> <div id="<%= dom_id(table) %>" class="table">
<h4><%= link_to table.name, table %></h4> <h4><%= link_to table.name, table %></h4>
<% if table.owner == Current.user %>
OWNER
<% end %>
</div> </div>

View File

@ -1,10 +1,11 @@
<% content_for :title, t(".tables") %> <% content_for :title, t(".tables") %>
<h2><%= t(".tables") %></h2>
<%= link_to t(".new_table"), new_table_path %> <%= link_to t(".new_table"), new_table_path %>
<h2>Tables you own</h2> <% if @tables.any? %>
<% if @owned_tables.any? %> <%= render @tables %>
<%= render @owned_tables %>
<% else %> <% else %>
<p>You do not own any tables.</p> <p><%= t(".no_tables") %></p>
<% end %> <% end %>

View File

@ -0,0 +1,3 @@
<%= htmlify_email(t(".content")) %>
<%= link_to t(".reset_password"), edit_password_reset_url(id: @user, token: @token) %>

View File

@ -0,0 +1,3 @@
<%= t(".content") %>
<%= edit_password_reset_url(id: @user, token: @token) %>

View File

@ -38,7 +38,13 @@
<%= display_form_errors(user, :password_confirmation) %> <%= display_form_errors(user, :password_confirmation) %>
</fieldset> </fieldset>
<hr>
<% if user.persisted? %> <% if user.persisted? %>
<%= f.label :avatar %>
<%= f.file_field :avatar %>
<%= display_form_errors(user, :avatar) %>
<%= f.label :profile %> <%= f.label :profile %>
<%= f.rich_text_area :profile %> <%= f.rich_text_area :profile %>
<%= display_form_errors(user, :profile) %> <%= display_form_errors(user, :profile) %>

View File

@ -1,6 +1,7 @@
<%= content_for :title, @user.username %> <%= content_for :title, @user.username %>
<h2><%= @user.username %></h2> <h2><%= @user.username %></h2>
<%= image_tag(url_for(@user.avatar.variant(:standard)), width: "100px", height: "100px") if @user.avatar.attached? %>
<aside> <aside>
<% if @user == Current.user %> <% if @user == Current.user %>

View File

@ -40,6 +40,7 @@ Rails.application.configure do
# Don't care if the mailer can't send. # Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false config.action_mailer.raise_delivery_errors = false
config.action_mailer.delivery_method = :letter_opener
config.action_mailer.perform_caching = false config.action_mailer.perform_caching = false

View File

@ -54,22 +54,42 @@ en:
destroy: destroy:
success: Successfully deleted “%{name}”. success: Successfully deleted “%{name}”.
error: “%{name}” could not be deleted. error: “%{name}” could not be deleted.
password_resets:
new:
reset_password: Reset your password
forgotten_password_intro: |-
If you have forgotten your password, enter your information below to reset it.
reset_password_button: Reset password
create:
success: Please check your email to reset your password.
error: Could not reset your password.
edit:
reset_password: Reset your password
invalid_token: That token seems to have expired, please try resettting your password again.
reset_password_button: Update password
update:
invalid_token: That token seems to have expired, please try resettting your password again.
success: Your password has been reset, you may now log in.
error: Failed to reset password. Please try again or contact us for help.
sessions: sessions:
create: create:
success: "Hello, %{name}!" success: "Hello, %{name}!"
error: "Could not sign in. Please check your username and password." error: "Could not sign in. Please check your username and password."
new: new:
log_in: Log in log_in: Log in
forgot_password: Forgotten your password?
destroy: destroy:
log_out: Log out log_out: Log out
success: "You have signed out." success: "You have signed out."
table_invite_mailer: table_invite_mailer:
invite_new_user: invite_new_user:
subject: Youve been invited to join a game on Tabletop Companion!
content: |- content: |-
Youve been invited to join a game on Tabletop Companion. To start playing, sign up at the Youve been invited to join a game on Tabletop Companion. To start playing, sign up at the
link below and accept your invitation! link below and accept your invitation!
sign_up: Sign up now sign_up: Sign up now
invite_user: invite_user:
subject: Youve been invited to a new game!
content: |- content: |-
You have been invited to join the table “%{table_name}” on Tabletop Companion. To You have been invited to join the table “%{table_name}” on Tabletop Companion. To
respond, visit the link below. respond, visit the link below.
@ -99,6 +119,7 @@ en:
index: index:
new_table: Create a table new_table: Create a table
tables: Tables tables: Tables
no_tables: You are currently not at any tables. Why not create one?
show: show:
edit_table: Edit table edit_table: Edit table
game_system: Game system game_system: Game system
@ -144,13 +165,22 @@ en:
error: Failed to update profile error: Failed to update profile
user_mailer: user_mailer:
email_verified: email_verified:
subject: Email verified on Tabletop Companion
content: |- content: |-
Thanks for verifying your email address, and welcome to Tabletop Companion! Thanks for verifying your email address, and welcome to Tabletop Companion!
You can now go ahead and log in. Enjoy! You can now go ahead and log in. Enjoy!
email_verification: email_verification:
subject: Verify your email on Tabletop Companion
content: |- content: |-
If you did not sign up for Tabletop Companion, please ignore this email. If you did not sign up for Tabletop Companion, please ignore this email.
Otherwise, please visit the link below to verify your email address. Otherwise, please visit the link below to verify your email address.
password_reset:
subject: Reset your password on Tabletop Companion
reset_password: Reset your password
content: |-
If you did not request a password reset, please ignore this email.
Otherwise, please visit the link below to reset your password.

View File

@ -10,6 +10,7 @@ Rails.application.routes.draw do
resources :users, only: [ :new, :create, :show, :edit, :update ] resources :users, only: [ :new, :create, :show, :edit, :update ]
resources :account_verifications, only: [ :show ] resources :account_verifications, only: [ :show ]
resources :password_resets, only: [ :new, :create, :edit, :update ]
resources :sessions, only: [ :new, :create, :destroy ] resources :sessions, only: [ :new, :create, :destroy ]
resources :table_invites, only: [ :index, :edit, :update ] resources :table_invites, only: [ :index, :edit, :update ]

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
require "test_helper"
class PasswordResetsControllerTest < ActionDispatch::IntegrationTest
test "should get new" do
get new_password_reset_path
assert_response :success
end
test "should send a password reset email" do
user = users(:trevor)
assert_emails(+1) do
post password_resets_path, params: { username: user.username }
assert_redirected_to new_session_path
end
end
test "should get edit" do
user = users(:trevor)
token = user.generate_token_for(:password_reset)
get edit_password_reset_path(token: token, id: user.username)
assert_response :success
end
test "should update password" do
user = users(:trevor)
token = user.generate_token_for(:password_reset)
put password_reset_path(id: user.username, token: token),
params: { password: "password", password_confirmation: "password" }
assert_redirected_to new_session_path
assert user.reload.authenticate("password")
end
end

View File

@ -1,5 +1,10 @@
- avatars - avatars
- list invites on user page - delete avatar
- default avatars
- discrete password page
- shared/private notes
- notifications - notifications
- Add characters to users/tables - Add characters to users/tables
- Character sheets/prototypes - Character sheets/prototypes
- chat
- maps