Compare commits

..

No commits in common. "0a046ed9c1aaa68656083d506dd95f55e091403f" and "8e269df3c1c93d0f924e3409b396776f82c8ebe8" have entirely different histories.

21 changed files with 91 additions and 459 deletions

View file

@ -82,7 +82,7 @@ $container_width: 800px
input, button, select, label
cursor: pointer
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], select, textarea
input[type=text], input[type=password], input[type=search], input[type=number], select, textarea
border-radius: 3px
background: #fff
border: 1px solid $input-border-color

View file

@ -1,56 +0,0 @@
@import "../partials/clean/constants"
body.auth_users-edit, body.auth_users-update
.settings-form
border: 1px solid $module-border-color
background: $module-bg-color
border-radius: 1em
padding: 1em 1.25em
&:not(:last-of-type)
margin-bottom: 2em
h2
font-size: 1.5rem
margin-bottom: .25em
.hint
font-style: italic
font-size: .85em
opacity: .9
fieldset
padding-block: .5em
fieldset:not(:last-of-type)
border-bottom: 1px solid $module-border-color
margin-bottom: .5em
.field
margin-bottom: 1em
.field_with_errors
display: inline
label
font-weight: bold
.error-explanation
color: $error-color
background: $error-bg-color
border: 1px solid $error-border-color
border-radius: .5em
padding: .5em
margin-bottom: .5em
header
font-weight: bold
ul
padding-left: 2em
.neopass-info
margin-bottom: .5em
.neopass-explanation
font-size: .85em

View file

@ -41,19 +41,17 @@
padding: .5em .75em .45em
color: #fff
text-decoration: none
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5)
-moz-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5)
-webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5)
text-shadow: 0 -1px 1px rgba(0, 0, 0, 0.25)
border-bottom: 1px solid rgba(0, 0, 0, 0.25)
position: relative
font-weight: bold
line-height: 1
&:hover:not(:disabled)
&:hover
color: #fff
&:active:not(:disabled)
&:active
transform: translateY(1px)
&:disabled
background: #999999 image-url("alert-overlay.png") repeat-x
cursor: not-allowed
=reset-awesome-button
border-radius: 0

View file

@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
before_action :set_locale
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :check_neopass_access, if: :devise_controller?
before_action :save_return_to_path,
if: ->(c) { c.controller_name == 'sessions' && c.action_name == 'new' }
@ -87,6 +88,12 @@ class ApplicationController < ActionController::Base
devise_parameter_sanitizer.permit(:account_update, keys: [:email])
end
def check_neopass_access
@can_use_neopass = (
params[:neopass] == Rails.configuration.neopass_access_secret
)
end
def save_return_to_path
if params[:return_to]
Rails.logger.debug "Saving return_to path: #{params[:return_to].inspect}"

View file

@ -1,60 +0,0 @@
class AuthUsersController < ApplicationController
before_action :authenticate_user!, except: [:new, :create]
def create
@auth_user = AuthUser.create(auth_user_params)
if @auth_user.persisted?
sign_in :auth_user, @auth_user
flash[:notice] = "Welcome to Dress to Impress, #{@auth_user.name}! 💖"
redirect_to root_path
else
render action: :new, status: :unprocessable_entity
end
end
def edit
@auth_user = current_auth_user
end
def new
@auth_user = AuthUser.new
end
def update
@auth_user = load_auth_user
# If the user has a password, then the `current_password` field is required
# when updating. If not, then it's not!
success = @auth_user.uses_password? ?
@auth_user.update_with_password(auth_user_params) :
@auth_user.update(auth_user_params)
if success
# NOTE: Changing the password will sign you out, so make sure we stay
# signed in!
bypass_sign_in @auth_user, scope: :auth_user
flash[:notice] = "Settings successfully saved."
redirect_to action: :edit
else
render action: :edit, status: :unprocessable_entity
end
end
private
def auth_user_params
params.require(:auth_user).permit(:name, :email, :password,
:password_confirmation, :current_password)
end
def load_auth_user
# Well, what we *actually* do is just use `current_auth_user`, and enforce
# that the provided user ID matches. The user ID param is only really for
# REST semantics and such!
raise AccessDenied unless auth_user_signed_in?
raise AccessDenied unless current_auth_user.id == params[:id].to_i
current_auth_user
end
end

View file

@ -1,31 +1,8 @@
class Devise::OmniauthCallbacksController < ApplicationController
rescue_from AuthUser::AuthAlreadyConnected, with: :auth_already_connected
rescue_from AuthUser::MissingAuthInfoError, with: :missing_auth_info
rescue_from ActiveRecord::RecordInvalid, with: :validation_failed
def neopass
case intent_param
when "login"
sign_in_with_neopass
when "connect"
connect_with_neopass
else
flash[:alert] = "Hrm, the NeoPass form you used was missing some " +
"\"intent\" information. That's surprising! Maybe try again?"
redirect_to root_path
end
end
def failure
flash[:warning] =
"Hrm, something went wrong in our connection to NeoPass, sorry! " +
"Maybe wait a moment and try again?"
redirect_to new_auth_user_session_path
end
private
def sign_in_with_neopass
@auth_user = AuthUser.from_omniauth(request.env["omniauth.auth"])
if @auth_user.previously_new_record?
@ -41,20 +18,11 @@ class Devise::OmniauthCallbacksController < ApplicationController
sign_in_and_redirect @auth_user, event: :authentication
end
def connect_with_neopass
raise AccessDenied unless auth_user_signed_in?
current_auth_user.connect_omniauth!(request.env["omniauth.auth"])
flash[:notice] = "We've successfully connected your NeoPass!"
redirect_to edit_auth_user_path
end
def auth_already_connected(error)
flash[:alert] = "This NeoPass is already connected to another account. " +
"You'll need to log out of this account, log in with that NeoPass, " +
"then disconnect it from the other account."
redirect_to return_path
def failure
flash[:warning] =
"Hrm, something went wrong in our connection to NeoPass, sorry! " +
"Maybe wait a moment and try again?"
redirect_to new_auth_user_session_path
end
def missing_auth_info(error)
@ -65,7 +33,7 @@ class Devise::OmniauthCallbacksController < ApplicationController
"Hrm, our connection with NeoPass didn't return the information we " +
"usually expect, sorry! If this keeps happening, please email me at " +
"matchu@openneo.net so I can help investigate!"
redirect_to return_path
redirect_to new_auth_user_session_path
end
def validation_failed(error)
@ -76,21 +44,6 @@ class Devise::OmniauthCallbacksController < ApplicationController
"Hrm, the connection with NeoPass worked, but we had trouble saving " +
"your account, sorry! If this keeps happening, please email me at " +
"matchu@openneo.net so I can help investigate!"
redirect_to return_path
end
def intent_param
request.env['omniauth.params']["intent"]
end
def return_path
case intent_param
when "login"
new_auth_user_session_path
when "connect"
edit_auth_user_path
else
root_path
end
redirect_to new_auth_user_session_path
end
end

View file

@ -1,29 +0,0 @@
class NeopassConnectionsController < ApplicationController
def destroy
@user = load_user
if @user.disconnect_neopass
flash[:notice] = "Your NeoPass has been disconnected. In the future, " +
"to log into this account, you'll need to use your password or your " +
"recovery email. You can also connect a different NeoPass, if you'd " +
"like."
else
flash[:alert] = "Whoops, there was an error disconnecting your " +
"NeoPass from your account, sorry. If this keeps happening, let us " +
"know!"
end
redirect_to edit_auth_user_path
end
private
def load_user
# Well, what we *actually* do is just use `current_user`, and enforce that
# the provided user ID matches. The user ID param is only really for REST
# semantics and such!
raise AccessDenied unless user_signed_in?
raise AccessDenied unless current_user.id == params[:user_id].to_i
current_user
end
end

View file

@ -69,10 +69,6 @@ module ApplicationHelper
end
end
def can_use_neopass
params[:neopass] == Rails.configuration.neopass_access_secret
end
def contact_email
"matchu@openneo.net"
end

View file

@ -8,15 +8,8 @@ class AuthUser < AuthRecord
validates :name, presence: true, uniqueness: {case_sensitive: false},
length: {maximum: 30}
validates :uid, uniqueness: {scope: :provider, allow_nil: true}
has_one :user, foreign_key: :remote_id, inverse_of: :auth_user
# If the email is blank, ensure that it's `nil` rather than an empty string,
# or else the database's uniqueness constraint will object to multiple users
# who all have the empty string as their email.
before_validation { self.email = nil if email.blank? }
# It's important to keep AuthUser and User in sync. When we create an AuthUser
# (e.g. through the registration process), we create a matching User, too. And
# when the AuthUser's name changes, we update User to match.
@ -40,96 +33,18 @@ class AuthUser < AuthRecord
end
def email_required?
# Email is required when creating a new account from scratch, but it isn't
# required when creating a new account via third-party login (e.g. it's
# already taken). It's also okay to remove your email address, though this
if new_record?
# When creating a new account, email is required when building it from
# scratch, but not required when using third-party login. This is mainly
# because third-party login can't reliably offer an unused email!
!uses_omniauth?
else
# TODO: I had wanted to make email required if you already have one, to
# make it harder to accidentally remove? I expected
# `email_before_last_save` to be the way to check this, but it
# seemed to be `nil` when calling this, go figure! For now, we're
# allowing email to be removed.
#
# NOTE: This is important for the case where you're disconnecting a
# NeoPass, but you don't have an email set, because your NeoPass
# email already belonged to another account. I don't think it makes
# sense to require people to add an alternate real email address in
# order to be able to disconnect a NeoPass from a DTI account they
# maybe even created by accident!
false
end
end
def password_required?
super && !uses_omniauth?
end
def uses_neopass?
provider == "neopass"
end
def neopass_friendly_id
neopass_email || uid
end
def uses_password?
encrypted_password?
end
def connect_omniauth!(auth)
raise MissingAuthInfoError, "Email missing" if auth.info.email.blank?
begin
update!(provider: auth.provider, uid: auth.uid,
neopass_email: auth.info.email)
rescue ActiveRecord::RecordInvalid
# If this auth is already bound to another account, raise a specific
# error about it, instead of the normal error.
if errors.where(:uid).any? { |e| e.type == :taken }
raise AuthAlreadyConnected, "there's already an account with " +
"provider #{auth.provider}, uid #{auth.uid}"
end
raise
end
end
def disconnect_neopass
# If there's no NeoPass, we're already done!
return true if !uses_neopass?
begin
# Remove all of the NeoPass fields, and return whether we were
# successful. (I don't know why it wouldn't be, but let's be resilient!)
#
# NOTE: I considered leaving `neopass_email` in place, to help us support
# users who accidentally got locked out… but I think it's more
# important to respect data privacy and not be holding onto an
# email address the user doesn't realize we have!
update(provider: nil, uid: nil, neopass_email: nil)
rescue => error
# If something strange happens, log it and gracefully return `false`!
Sentry.capture_exception error
Rails.logger.error error
false
end
end
def self.find_by_omniauth(auth)
find_by(provider: auth.provider, uid: auth.uid)
end
def self.from_omniauth(auth)
raise MissingAuthInfoError, "Email missing" if auth.info.email.blank?
transaction do
find_or_create_by!(provider: auth.provider, uid: auth.uid) do |user|
# This account is new! Let's do the initial setup.
# TODO: Can we somehow get the Neopets username if one exists, instead
# of just using total randomness?
user.name = build_unique_username
@ -140,17 +55,6 @@ class AuthUser < AuthRecord
# password recovery!)
email_exists = AuthUser.where(email: auth.info.email).exists?
user.email = auth.info.email unless email_exists
end.tap do |user|
# If this account already existed, make sure we've saved the latest
# email to `neopass_email`.
#
# We track this separately from `email`, which the user can edit, to
# use in the Settings UI to indicate what NeoPass you're linked to. (In
# practice, this *shouldn't* ever change after initial setup, because
# NeoPass emails are immutable? But why not be resilient!)
unless user.previously_new_record?
user.update!(neopass_email: auth.info.email)
end
end
end
end
@ -183,6 +87,5 @@ class AuthUser < AuthRecord
"\"#{base_name}\" are taken??)"
end
class AuthAlreadyConnected < ArgumentError;end
class MissingAuthInfoError < ArgumentError;end
end

View file

@ -4,7 +4,6 @@ class User < ApplicationRecord
PreviewTopContributorsCount = 3
belongs_to :auth_user, foreign_key: :remote_id, inverse_of: :user
delegate :disconnect_neopass, to: :auth_user
has_many :closet_hangers
has_many :closet_lists

View file

@ -1,117 +0,0 @@
<h2>Settings</h2>
<%= form_with(model: @auth_user, method: :put, class: "settings-form") do |f| %>
<h2>Your info</h2>
<%= render "devise/shared/error_messages", resource: @auth_user %>
<fieldset>
<div class="field">
<%= f.label :name, 'DTI Username' %>
<span class="hint">Use this to log in to Dress to Impress!</span>
<br />
<%= f.text_field :name, autocomplete: "username" %>
</div>
<div class="field">
<%= f.label :email %>
<span class="hint">This can help you recover your account later.</span>
<br />
<%= f.email_field :email, autocomplete: "email" %>
</div>
</fieldset>
<fieldset>
<div class="field">
<%= f.label :password, "New password" %>
<span class="hint">Leave blank if you don't want to change it.</span>
<br />
<%= f.password_field :password, autocomplete: "new-password" %>
<% if @minimum_password_length %>
<br />
<span class="hint"><%= @minimum_password_length %> characters minimum</span>
<% end %>
</div>
<div class="field">
<%= f.label :password_confirmation, "New password confirmation" %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
</fieldset>
<%# Current password is only required if you have one! %>
<% if @auth_user.uses_password? %>
<fieldset>
<div class="field">
<%= f.label :current_password %>
<span class="hint">We need your current password to confirm your changes.</span>
<br />
<%= f.password_field :current_password, autocomplete: "current-password" %>
</div>
</fieldset>
<% end %>
<div class="actions">
<%= f.submit "Save changes" %>
</div>
<% end %>
<% if @auth_user.uses_neopass? %>
<%= form_with url: user_neopass_connection_path(@auth_user.user),
method: :delete, class: "settings-form", data: {
turbo_confirm: "Are you sure? Without a NeoPass, you'll need to use " +
"your password or your recovery email \"#{@auth_user.email}\" to " +
"log in again.\n\nMake sure you have everything all set up first! " +
"Otherwise, you might be locked out of this account forever!"
} do |form|
%>
<h2>Your NeoPass</h2>
<section class="neopass-info">
<strong>
NeoPass ID:
</strong>
<%= @auth_user.neopass_friendly_id %>
</section>
<section class="neopass-explanation">
<p>
You can log into your Dress to Impress account with NeoPass, or with
your username and password. If you ever lose access to your NeoPass,
you can still use "Forgot your password?" to recover your Dress to
Impress account, using the Email saved in "Your info".
</p>
<% if !@auth_user.uses_password? && !@auth_user.email %>
<p>
You can't remove this NeoPass yet, because you need to either set a
password or a recovery email first. (Ideally both!)
</p>
<% elsif !@auth_user.uses_password? %>
<p>
Be extra careful here! Your account doesn't have a password set.
</p>
<% elsif !@auth_user.email? %>
<p>
Be extra careful here! Your account doesn't have an email set.
</p>
<% end %>
</section>
<%= form.submit "Disconnect your NeoPass",
disabled: !@auth_user.uses_password? && !@auth_user.email? %>
<% end %>
<% elsif can_use_neopass %>
<%= form_with url: auth_user_neopass_omniauth_authorize_path(intent: "connect"),
method: :post, class: "settings-form", data: {turbo: false} do |form|
%>
<h2>Your NeoPass</h2>
<section class="neopass-explanation">
<p>
If you connect a NeoPass, you can use it to log into this DTI account!
You'll still be able to use your password to log in too, and you can
disconnect this later if you'd like.
</p>
</section>
<%= form.submit "Connect your NeoPass" %>
<% end %>
<% end %>
<% content_for :stylesheets do %>
<%= stylesheet_link_tag "auth_users/edit" %>
<% end %>

View file

@ -0,0 +1,44 @@
<h2>Settings</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 :name, 'Username' %><br />
<%= f.text_field :name, autofocus: true, autocomplete: "username" %>
</div>
<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 %>
<%= link_to "Back", :back %>

View file

@ -1,7 +1,7 @@
<h2>Sign up</h2>
<%= form_with(model: @auth_user, method: :post) do |f| %>
<%= render "devise/shared/error_messages", resource: @auth_user %>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<p>
Choose a username, and an email address we can use to reset your password.

View file

@ -1,10 +1,10 @@
<h2>Log in</h2>
<% if can_use_neopass %>
<% if @can_use_neopass %>
🌟✨🌟✨🌟✨🌟✨🌟
<br />
<%= button_to "Log in with NeoPass",
auth_user_neopass_omniauth_authorize_path(intent: "login"),
auth_user_neopass_omniauth_authorize_path,
data: {turbo: false} # Turbo can't handle this redirect!
%>
🌟✨🌟✨🌟✨🌟✨🌟

View file

@ -1,9 +1,11 @@
<% if resource.errors.any? %>
<div class="error-explanation" data-turbo-cache="false">
<header>
<%= I18n.t("errors.messages.not_saved", count: resource.errors.count,
resource: "user") %>
</header>
<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>

View file

@ -1,11 +1,19 @@
<%- if controller_name != 'sessions' %>
<%= link_to "Log in", new_auth_user_session_path %><br />
<%= link_to "Log in", new_session_path(resource_name) %><br />
<% end %>
<%- if controller_name != 'auth_users' %>
<%= link_to "Sign up", new_auth_user_path %><br />
<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to "Sign up", new_registration_path(resource_name) %><br />
<% end %>
<%- if controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to "Forgot your password?", new_auth_user_password_path %><br />
<%- 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 %>

View file

@ -49,7 +49,7 @@
= userbar_contributions_summary(current_user)
= link_to t('.userbar.items'), user_closet_hangers_path(current_user), :id => 'userbar-items-link'
= link_to t('.userbar.outfits'), current_user_outfits_path
= link_to t('.userbar.settings'), edit_auth_user_path
= link_to t('.userbar.settings'), edit_auth_user_registration_path
= button_to t('.userbar.logout'), destroy_auth_user_session_path, method: :delete,
params: {return_to: request.fullpath}
- else

View file

@ -2,9 +2,7 @@ OpenneoImpressItems::Application.routes.draw do
root :to => 'outfits#new'
# Login and account management!
devise_for :auth_users, path: "users", skip: [:registrations]
resources :auth_users, only: [:new, :create, :update]
get '/users/edit', to: 'auth_users#edit', as: 'edit_auth_user'
devise_for :auth_users, path: "users"
# The outfit editor!
# TODO: It's a bit silly that outfits/new points to outfits#edit.
@ -68,8 +66,6 @@ OpenneoImpressItems::Application.routes.draw do
resources :neopets_connections, path: 'neopets-connections',
only: [:create, :destroy]
resource :neopass_connection, path: "neopass-connection", only: [:destroy]
end
get 'users/current-user/closet' => 'closet_hangers#index', :as => :your_items

View file

@ -1,5 +0,0 @@
class AddNeoPassEmailToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :neopass_email, :string
end
end

View file

@ -1,5 +0,0 @@
class AddUniqueIndexForOmniauthToUsers < ActiveRecord::Migration[7.1]
def change
add_index :users, [:provider, :uid], unique: true
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_04_08_120359) do
ActiveRecord::Schema[7.1].define(version: 2024_04_01_124406) do
create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
t.string "name", limit: 30, null: false
t.string "encrypted_password", limit: 64
@ -31,9 +31,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_04_08_120359) do
t.datetime "remember_created_at"
t.string "provider"
t.string "uid"
t.string "neopass_email"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
end