diff --git a/Gemfile b/Gemfile index 3f9c19bd..4b42a719 100644 --- a/Gemfile +++ b/Gemfile @@ -68,7 +68,7 @@ gem 'annotate' gem 'attribute_normalizer' gem 'carrierwave' gem 'dalli' -gem 'devise' +gem 'devise', git: 'https://github.com/heartcombo/devise.git' gem 'mini_magick' gem 'mini_racer' gem 'protected_attributes_continued' diff --git a/Gemfile.lock b/Gemfile.lock index 4d1fae96..6dc83a53 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,14 @@ +GIT + remote: git@github.com:heartcombo/devise.git + revision: a259ff3c28912a27329727f4a3c2623d3f5cb6f2 + specs: + devise (5.0.0.beta) + bcrypt (~> 3.0) + orm_adapter (~> 0.1) + railties (>= 6.0.0) + responders + warden (~> 1.2.3) + GEM remote: https://rubygems.org/ specs: @@ -129,12 +140,6 @@ GEM debug (1.9.2) irb (~> 1.10) reline (>= 0.3.8) - devise (4.9.4) - bcrypt (~> 3.0) - orm_adapter (~> 0.1) - railties (>= 4.1.0) - responders - warden (~> 1.2.3) diff-lcs (1.5.1) docile (1.4.0) drb (2.2.1) @@ -451,7 +456,7 @@ DEPENDENCIES cssbundling-rails dalli debug - devise + devise! factory_bot_rails faker flatpickr diff --git a/app/assets/images/fnf-bw.png b/app/assets/images/logos/fnf-bw.png similarity index 100% rename from app/assets/images/fnf-bw.png rename to app/assets/images/logos/fnf-bw.png diff --git a/app/assets/images/logos/fnf-large.png b/app/assets/images/logos/fnf-large.png new file mode 100644 index 00000000..514794f0 Binary files /dev/null and b/app/assets/images/logos/fnf-large.png differ diff --git a/app/assets/images/logos/fnf-transparent.png b/app/assets/images/logos/fnf-transparent.png new file mode 100644 index 00000000..04681108 Binary files /dev/null and b/app/assets/images/logos/fnf-transparent.png differ diff --git a/app/assets/images/fnf.png b/app/assets/images/logos/fnf.png similarity index 100% rename from app/assets/images/fnf.png rename to app/assets/images/logos/fnf.png diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index b455e3e4..1d28a571 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -10,7 +10,6 @@ * compiled file, but it's generally better to create a new file per style scope. * *= require_self - *= require_tree . */ @import 'bootstrap/scss/bootstrap'; @@ -22,10 +21,11 @@ //@import '../../../node_modules/flatpickr/dist/flatpickr.min'; //@import '../../../node_modules/flatpickr/dist/themes/material_orange'; -@import './global.css.scss'; @import './events.css.scss'; @import './home.css.scss'; @import './jobs.css.scss'; @import './payments.css.scss'; @import './shifts.css.scss'; @import './time_slots.css.scss'; +@import './links.css.scss'; +@import './global.css.scss'; diff --git a/app/assets/stylesheets/events.css.scss b/app/assets/stylesheets/events.css.scss index 0aa8feea..6dce2fc2 100644 --- a/app/assets/stylesheets/events.css.scss +++ b/app/assets/stylesheets/events.css.scss @@ -18,8 +18,8 @@ } img.event-preview { - border: 5px solid #999; + max-width: 200px; + max-height: 200px; margin: 0 30px; - border-radius: 20px; float: right !important; } diff --git a/app/assets/stylesheets/global.css.scss b/app/assets/stylesheets/global.css.scss index f742371b..3c94c60f 100644 --- a/app/assets/stylesheets/global.css.scss +++ b/app/assets/stylesheets/global.css.scss @@ -6,16 +6,78 @@ clear: right; } -a { +.container-fluid, .container { + a { + color: #ff7800 !important; + text-decoration: none !important; + padding: 6px 15px !important; + margin-left: -15px; + + &:focus, + &:hover { + color: #000 !important; + background-color: #ff7800 !important; + } + } +} + +.muted { + font-size: 10pt; + color: #888; + margin-left: 22px; +} + +a.btn { color: #ff7800 !important; text-decoration: none !important; - padding: 6px !important; - margin-left: -6px; + padding: 6px 15px !important; + margin-right: 10px; + margin-left: 0; + border: 1px solid #8f4603; + border-radius: 10px !important; + background-color: #462301; + &:focus, &:hover { color: #000 !important; background-color: #ff7800 !important; - border-radius: 10px !important; + } +} + + +nav.navbar { + width: 100%; + padding-left: 20px; + + a.nav-brand { + display: inline-block; + border-width: 0 !important; + padding: 0 !important; + background-color: transparent !important; + border-radius: 0; + margin-left: 30px; + } + + a.nav-brand img { + display: inline-block; + position: relative; + margin-left: 50px !important; + margin-right: 30px !important; + padding: 20px !important; + } + + a.nav-link { + color: #ff7800 !important; + text-decoration: none !important; + padding: 6px 15px !important; + margin: 0 10px; + background-color: #462301; + + &:focus, + &:hover { + color: #000 !important; + background-color: #ff7800 !important; + } } } @@ -28,26 +90,89 @@ a { } } +h1, h2, h3, h4, h5, legend { + margin-top: 20px; +} + +.alert { + h3 { + margin: 0; + text-align: center; + } +} + +.field-group { + position: relative; + width: 100%; + display: block; + border: 1px solid red; +} + +.vertical-20 { + display: block; + width: 100%; + height: 20px; +} + +.nowrap { + white-space: nowrap; +} + +dl.dl-horizontal { + margin-top: 20px; + + dt { + color: #fa2; + font-weight: 900; + } +} + form { label { margin-top: 25px; margin-bottom: 2px; display: block; - min-width: 400px; - font-size: 10pt; + min-width: auto; + font-size: 12pt; + vertical-align: top; } + input { background-color: #efe; color: #000; border: 0.5px solid #777; border-radius: 5px; - padding: 5px; + padding: 5px 0; margin-top: 0; - margin-bottom: 15px; - display: block !important; + margin-right: 5px; + margin-bottom: 0; + display: inline-block !important; max-width: 80%; - min-width: 400px !important; + } + + input[type=text] { + min-width: 100px; + text-align: left; + padding-left: 10px; + width: 300px; + } + + input[type=submit] { + margin-top: 20px; + margin-bottom: 20px; + } + + input[type=number] { + text-align: right; + width: 80px; + min-width: 30px; + } + + input[type=email], input[type=password] { + padding: 9px !important; + width: 300px; + min-width: 100px; } } @@ -86,11 +211,9 @@ body { } // Hide navbar text for small screens -@media (max-width: 410px) { - .user-name, - .sign-in-text, - .sign-out-text { - display: none; +@media (max-width: 600px) { + small.nowrap { + white-space: normal; } } diff --git a/app/assets/stylesheets/links.css.scss b/app/assets/stylesheets/links.css.scss new file mode 100644 index 00000000..6fb1d7a6 --- /dev/null +++ b/app/assets/stylesheets/links.css.scss @@ -0,0 +1,11 @@ +ul.shared-user-links { + list-style-type: none; + margin-top: 40px !important; + padding-left: 0 !important; + p { + margin-left: 0; + a { + margin-left: 0; + } + } +} diff --git a/app/assets/stylesheets/users/shared/links.css.scss b/app/assets/stylesheets/users/shared/links.css.scss deleted file mode 100644 index 3ce933d7..00000000 --- a/app/assets/stylesheets/users/shared/links.css.scss +++ /dev/null @@ -1,4 +0,0 @@ -.shared-user-links { - list-style-type: none; - margin-left: 0; -} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7f730d4c..c64a53ae 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,8 +2,6 @@ # General controller configuration and helpers. class ApplicationController < ActionController::Base - # allow_browser versions: :modern - protect_from_forgery # Allow user to log in via authentication token @@ -31,7 +29,9 @@ def require_logged_in_user end def configure_permitted_parameters - devise_parameter_sanitizer.permit(:sign_up, keys: [:name]) + attributes = %i[name first last email avatar] + devise_parameter_sanitizer.permit(:sign_up, keys: attributes) + devise_parameter_sanitizer.permit(:account_update, keys: attributes) end def authenticate_user_from_token! diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb index e87186d6..d85f433a 100644 --- a/app/helpers/time_helper.rb +++ b/app/helpers/time_helper.rb @@ -5,8 +5,15 @@ module TimeHelper TIME_FORMAT = '%m/%d/%Y, %H:%M %p %Z' + DISPLAY_FORMAT = '%A, %d %B %Y, %h:%M %p' class << self + def for_display(datetime) + return nil if datetime.nil? + + datetime.strftime(DISPLAY_FORMAT) if [Date, DateTime, Time, ActiveSupport::TimeWithZone].include?(datetime.class) + end + def to_string_for_flatpickr(datetime) return nil if datetime.nil? diff --git a/app/javascript/application.js b/app/javascript/application.js index 9b76958a..91db04e3 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -2,20 +2,17 @@ import "@hotwired/turbo-rails" import "@hotwired/stimulus" - +import "@popperjs/core"; import "bootstrap"; -// import "bootstrap/dist/css/bootstrap.min.css"; -// import 'flatpickr/dist/flatpickr.min.css'; import 'flatpickr/dist/flatpickr.min.js'; import "./add_jquery" import "./controllers" - import "./payments" import "./popovers" import "./ticket_requests" import "./datepicker" -// import * as bootstrap from "bootstrap" + diff --git a/app/javascript/popovers.ts b/app/javascript/popovers.js similarity index 55% rename from app/javascript/popovers.ts rename to app/javascript/popovers.js index 7eb99a5d..b9c94748 100644 --- a/app/javascript/popovers.ts +++ b/app/javascript/popovers.js @@ -1,11 +1,11 @@ import jQuery from 'jquery'; (function ($) { - const popover: JQuery = $('.help-popover'); + const popover = $('.help-popover'); popover({ trigger: 'manual' }).on('click', function (e) { - const popoverClicked: JQuery = $(this); + const popoverClicked = $(this); popoverClicked('toggle'); e.preventDefault(); }); @@ -13,8 +13,8 @@ import jQuery from 'jquery'; (function () { jQuery(function () { - $("a[rel~=popover], .has-popover").popover(); - return $("a[rel~=tooltip], .has-tooltip").tooltip(); + $("a[rel~=popover], .has-popover").popover; + return $("a[rel~=tooltip], .has-tooltip").tooltip; }); }).call(this); diff --git a/app/models/user.rb b/app/models/user.rb index ccb86961..efd202a9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,6 +14,8 @@ # email :string not null # encrypted_password :string not null # failed_attempts :integer default(0) +# first :text +# last :text # last_sign_in_at :datetime # last_sign_in_ip :string # locked_at :datetime @@ -38,22 +40,41 @@ # A real-life living breathing human. class User < ApplicationRecord - # Include default devise modules. Others available are: - # :token_authenticatable, :timeoutable and :omniauthable - devise :database_authenticatable, :registerable, :lockable, - :recoverable, :rememberable, :trackable, :validatable + # @see https://dev.to/kevinluo201/introduction-to-devise-modules-and-enable-all-of-them-4p25 + DEVISE_MODULES = %i[ + database_authenticatable + registerable + lockable + recoverable + rememberable + trackable + validatable + ].freeze + + devise(*DEVISE_MODULES) + + class << self + define_method 'has_devise_module?' do |module_name| + DEVISE_MODULES.include?(module_name) + end + end has_many :event_admins, dependent: :destroy has_many :events_administrated, through: :event_admins, source: :event has_many :ticket_requests has_one :site_admin, dependent: :destroy - MAX_NAME_LENGTH = 70 - MAX_EMAIL_LENGTH = 254 # Based on RFC 3696; see http://isemail.info/about - MAX_PASSWORD_LENGTH = 255 + MAX_NAME_LENGTH = 40 + MAX_EMAIL_LENGTH = 80 # Based on RFC 3696; see http://isemail.info/about + MAX_PASSWORD_LENGTH = 40 + + before_validation :canonize_full_name! normalize_attributes :name, :email + validates :first, presence: true + validates :last, presence: true + validates :name, presence: true, length: { maximum: MAX_NAME_LENGTH }, format: { with: /\A\S+\s\S+(\s\S+)*\z/i, @@ -64,7 +85,11 @@ class User < ApplicationRecord length: { maximum: MAX_EMAIL_LENGTH } def first_name - name.split.first + first + end + + def last_name + last end def site_admin? @@ -80,4 +105,15 @@ def generate_auth_token! update_attribute(:authentication_token, token) token end + + private + + def canonize_full_name! + if name && first.nil? && last.nil? + self.first, self.last = *name.split(/\s+/).map(&:strip) + elsif name.nil? && first.present? + self.name = first if first + self.name += " #{last}" if name && last + end + end end diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml new file mode 100644 index 00000000..9697b2ae --- /dev/null +++ b/app/views/devise/confirmations/new.html.haml @@ -0,0 +1,18 @@ +%h2 Resend Confirmation Instructions + += form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| + = render "devise/shared/error_messages", resource: resource + + %h2 Didn't receive a confirmation email? + + %p.lead + Fill out the form below with the email you used to register an account and we'll resend the confirmation email. + + .field + %p= f.label :email + %p= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) + + .actions + = f.submit "Resend Confirmation Instructions", class: 'btn btn-primary btn-large' + += render "devise/shared/links" diff --git a/app/views/devise/mailer/confirmation_instructions.html.haml b/app/views/devise/mailer/confirmation_instructions.html.haml new file mode 100644 index 00000000..99326d7e --- /dev/null +++ b/app/views/devise/mailer/confirmation_instructions.html.haml @@ -0,0 +1,8 @@ +%p + Hi #{@resource.first}! + +%p + Thanks for registering an account. You can confirm your email through the following link: + +%p + = link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) diff --git a/app/views/devise/mailer/email_changed.html.haml b/app/views/devise/mailer/email_changed.html.haml new file mode 100644 index 00000000..94ebba05 --- /dev/null +++ b/app/views/devise/mailer/email_changed.html.haml @@ -0,0 +1,8 @@ +%p + Hello #{@email}! +- if @resource.try(:unconfirmed_email?) + %p + We're contacting you to notify you that your email is being changed to #{@resource.unconfirmed_email}. +- else + %p + We're contacting you to notify you that your email has been changed to #{@resource.email}. diff --git a/app/views/devise/mailer/password_change.html.haml b/app/views/devise/mailer/password_change.html.haml new file mode 100644 index 00000000..ab7c04c4 --- /dev/null +++ b/app/views/devise/mailer/password_change.html.haml @@ -0,0 +1,3 @@ +%p + Hello #{@resource.email}! +%p We're contacting you to notify you that your password has been changed. diff --git a/app/views/devise/mailer/reset_password_instructions.html.haml b/app/views/devise/mailer/reset_password_instructions.html.haml new file mode 100644 index 00000000..0711cd5a --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.html.haml @@ -0,0 +1,6 @@ +%p + Hello #{@resource.email}! +%p Someone has requested a link to change your password. You can do this through the link below. +%p= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) +%p If you didn't request this, please ignore this email. +%p Your password won't change until you access the link above and create a new one. diff --git a/app/views/devise/mailer/unlock_instructions.html.haml b/app/views/devise/mailer/unlock_instructions.html.haml new file mode 100644 index 00000000..282c98a2 --- /dev/null +++ b/app/views/devise/mailer/unlock_instructions.html.haml @@ -0,0 +1,5 @@ +%p + Hello #{@resource.email}! +%p Your account has been locked due to an excessive number of unsuccessful sign in attempts. +%p Click the link below to unlock your account: +%p= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml new file mode 100644 index 00000000..9ea4080b --- /dev/null +++ b/app/views/devise/passwords/edit.html.haml @@ -0,0 +1,17 @@ +%h2 Change your password += 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 + .field + %p= f.label :password, "New password" + - if @minimum_password_length + %p + %em + (#{@minimum_password_length} characters minimum) + %p= f.password_field :password, autofocus: true, autocomplete: "new-password" + .field + %p= f.label :password_confirmation, "Confirm new password" + %p= f.password_field :password_confirmation, autocomplete: "new-password" + .actions + = f.submit "Change my password", class: 'btn btn-primary btn-large' += render "devise/shared/links" diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml new file mode 100644 index 00000000..7ad92bed --- /dev/null +++ b/app/views/devise/passwords/new.html.haml @@ -0,0 +1,14 @@ +%h2 Forgot your password? + += form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| + + = render "devise/shared/error_messages", resource: resource + + .field + %p= f.label :email + %p= f.email_field :email, autofocus: true, autocomplete: "email" + + .actions + = f.submit "Send me password reset instructions", class: 'btn btn-primary btn-large' + += render "devise/shared/links" diff --git a/app/views/devise/registrations/_change_password.html.haml b/app/views/devise/registrations/_change_password.html.haml new file mode 100644 index 00000000..e54c8e7e --- /dev/null +++ b/app/views/devise/registrations/_change_password.html.haml @@ -0,0 +1,20 @@ +.row + .col-md-6.col-sm-12 + .vertical-20 + - if existing + %small.nowrap NOTE: leave these fields blank if you don't want to change your password. + - else + %h3 Please enter your new password, twice for confirmation. + +.row + .col-md-6.col-sm-12 + = f.label :password + = f.password_field :password, autocomplete: "new-password" + - if @minimum_password_length + %small.nowrap + NOTE: + = "#{@minimum_password_length} characters minimum" + + .col-6 + %p= f.label :password_confirmation + %p= f.password_field :password_confirmation, autocomplete: "new-password" diff --git a/app/views/devise/registrations/_form.html.haml b/app/views/devise/registrations/_form.html.haml new file mode 100644 index 00000000..5092aa38 --- /dev/null +++ b/app/views/devise/registrations/_form.html.haml @@ -0,0 +1,57 @@ + +- http_method = existing ? :put : :post += form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: http_method }) do |f| + + = render "devise/shared/error_messages", resource: resource + + .container-fluid + .row + .col-md-6.col-sm-12 + = f.label :first, 'First Name' + = f.text_field :first, maxlength: User::MAX_NAME_LENGTH, required: true + + .col-md-6.col-sm-12 + = f.label :last, 'Last Name' + = f.text_field :last, maxlength: User::MAX_NAME_LENGTH, required: true + + .row + .col-md-8.col-sm-11 + = f.label :email, 'Your Email Address' + = f.email_field :email, placeholder: resource.email || resource.unconfirmed_email || 'dj-awesome@gmail.com', maxlength: User::MAX_EMAIL_LENGTH, required: true, autofocus: true, autocomplete: "email" + .col-md-4.col-sm-1 + + = render partial: 'change_password', locals: { existing: existing, f: f } + %br + %hr + - if existing + .row + .col-md-6.col-sm-12 + %h5.nowrap + We need your current password to confirm your changes + = f.label :current_password + = f.password_field :current_password, autocomplete: "current-password" + + .row + .col-12 + .actions + = f.submit "Update", class: 'btn btn-primary btn-large w-1' + + - unless existing + + .row + .col-12 + .actions + = f.submit "Register", class: 'btn btn-primary btn-large' + + .row + .col-12 + .actions + %h3 + Close my accounts completely. + %div + Wanna go? #{button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?" }, class: 'btn btn-secondary btn-large', method: :delete} + + .row + .col-12 + = link_to "Go Back ↩".html_safe, :back + diff --git a/app/views/devise/registrations/edit.html.haml b/app/views/devise/registrations/edit.html.haml new file mode 100644 index 00000000..056417cd --- /dev/null +++ b/app/views/devise/registrations/edit.html.haml @@ -0,0 +1,4 @@ +%h2 + Edit Your Account + += render partial: 'form', locals: { existing: resource&.persisted?, resource: resource, resource_name: resource_name} diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml new file mode 100644 index 00000000..02d31e9e --- /dev/null +++ b/app/views/devise/registrations/new.html.haml @@ -0,0 +1,3 @@ +%h2 Register a New Account + += render partial: 'form', locals: { existing: resource&.persisted?, resource: resource, resource_name: resource_name} diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml new file mode 100644 index 00000000..3449e916 --- /dev/null +++ b/app/views/devise/sessions/new.html.haml @@ -0,0 +1,23 @@ +%h2 Log in + += form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| + + .field + %p= f.label :email + %p= f.email_field :email, autofocus: true, autocomplete: "email" + + .field + %p= f.label :password + %p= f.password_field :password, autocomplete: "current-password" + + - if devise_mapping.rememberable? + .field + %p= f.check_box :remember_me + %p= f.label :remember_me + + .actions + = f.submit "Log in", class: 'btn btn-primary btn-large' + +%hr + += render "devise/shared/links" diff --git a/app/views/devise/shared/_error_messages.html.haml b/app/views/devise/shared/_error_messages.html.haml new file mode 100644 index 00000000..c34097ea --- /dev/null +++ b/app/views/devise/shared/_error_messages.html.haml @@ -0,0 +1,10 @@ +- if resource.errors.any? + .alert.alert-danger + #error_explanation{"data-turbo-cache" => "false"} + %h2 + = I18n.t("errors.messages.not_saved", | + count: resource.errors.count, | + resource: resource.class.model_name.human.downcase) | + %ul + - resource.errors.full_messages.each do |message| + %li= message diff --git a/app/views/devise/shared/_links.html.haml b/app/views/devise/shared/_links.html.haml new file mode 100644 index 00000000..f33911a3 --- /dev/null +++ b/app/views/devise/shared/_links.html.haml @@ -0,0 +1,19 @@ +%ul.shared-user-links + - if controller_name != 'sessions' + %p= link_to "Sign in", new_session_path(resource_name) + + - if devise_mapping.registerable? && controller_name != 'registrations' + %p= link_to "Register", new_registration_path(resource_name) + + - if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' + %p= link_to "Forgot your password?", new_password_path(resource_name) + + - if devise_mapping.confirmable? && controller_name != 'confirmations' + %p= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) + + - if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' + %p= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) + + - if devise_mapping.omniauthable? + - resource_class.omniauth_providers.each do |provider| + %p= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml new file mode 100644 index 00000000..84c54502 --- /dev/null +++ b/app/views/devise/unlocks/new.html.haml @@ -0,0 +1,9 @@ +%h2 Resend unlock instructions += form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| + = render "devise/shared/error_messages", resource: resource + .field + %p= f.label :email + %p= f.email_field :email, autofocus: true, autocomplete: "email" + .actions + = f.submit "Resend unlock instructions" += render "devise/shared/links" diff --git a/app/views/events/show.html.haml b/app/views/events/show.html.haml index 9f09ba77..7d34e4aa 100644 --- a/app/views/events/show.html.haml +++ b/app/views/events/show.html.haml @@ -18,13 +18,13 @@ Edit %i.icon-edit -= image_tag image_path('fnf.png'), class: 'event-preview' += image_tag image_path('logos/fnf-transparent.png'), class: 'event-preview' %dl.dl-horizontal %dt Start Time - %dd= @event.start_time.localtime.to_formatted_s(:friendly) + %dd= TimeHelper.for_display(@event.start_time.localtime) %dt End Time - %dd= @event.end_time.localtime.to_formatted_s(:friendly) + %dd= TimeHelper.for_display(@event.end_time.localtime) %dt Adult Ticket Price %dd = number_to_currency(@event.adult_ticket_price) diff --git a/app/views/layouts/__alternative.html.haml b/app/views/layouts/__alternative.html.haml deleted file mode 100644 index 6e764f57..00000000 --- a/app/views/layouts/__alternative.html.haml +++ /dev/null @@ -1,90 +0,0 @@ -!!! -%html{ lang: 'en', 'data-bs-theme': 'dark' } - %head - %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/ - %meta{:content => "width=device-width,initial-scale=1", :name => "viewport"}/ - %meta{:content => "yes", :name => "apple-mobile-web-app-capable"}/ - - = csrf_meta_tags - = yield :meta - - %link{:href => "/manifest.json", :rel => "manifest"}/ - %link{:href => "/icon.png", :rel => "icon", :type => "image/png"}/ - %link{:href => "/icon.svg", :rel => "icon", :type => "image/svg+xml"}/ - %link{:href => "/icon.png", :rel => "apple-touch-icon"}/ - - %title - = PRODUCT_NAME - - if content_for?(:title) - — - = yield(:title) - - // Bootstrap CSS - %link{:crossorigin => "anonymous", :href => "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css", :integrity => "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH", :rel => "stylesheet"}/ - - // Optional theme - %link{:crossorigin => "anonymous", :href => "https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css", :integrity => "sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp", :rel => "stylesheet"}/ - - // Popover JS - %script{:crossorigin => "anonymous", :integrity => "sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r", :src => "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"} - - // Bootstrap Minified JS - %script{:crossorigin => "anonymous", :integrity => "sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy", :src => "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"} - - // Bootstrap Datepicker JS - %script{:crossorigin => "anonymous", :integrity => "sha512-LsnSViqQyaXpD4mBBdRYeP6sRwJiJveh2ZIbW41EBrNmKxgr/LFZIiWT6yr+nycvhvauz8c2nYMhrP80YhG7Cw==", :referrerpolicy => "no-referrer", :src => "https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.10.0/js/bootstrap-datepicker.min.js"} - - // Bootstrap Datepicker CSS - %link{:crossorigin => "anonymous", :href => "https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.10.0/css/bootstrap-datepicker.min.css", :integrity => "sha512-34s5cpvaNG3BknEWSuOncX28vz97bRI59UnVtEEpFX536A7BtZSJHsDyFoCl8S7Dt2TPzcrCEoHBGeM4SUBDBw==", :referrerpolicy => "no-referrer", :rel => "stylesheet"}/ - - // JQuery JS - %script{:crossorigin => "anonymous", :integrity => "sha256-kmHvs0B+OpCW5GVHUNjv9rOmY0IvSIRcf7zGUDTDQM8=", :src => "https://code.jquery.com/jquery-3.7.1.slim.min.js"} - - // JQuery UI - %script{:crossorigin => "anonymous", :integrity => "sha256-lSjKY0/srUM9BE3dPm+c4fBo1dky2v27Gdjm2uoZaL0=", :src => "https://code.jquery.com/ui/1.13.2/jquery-ui.min.js"} - - - = javascript_include_tag 'application' - = stylesheet_link_tag 'application', media: 'all' - - = yield :head - - %body - .navbar.navbar-expand-lg.border-bottom.border-body - .container-fluid - %a.navbar-brand{ href: root_path } - = PRODUCT_NAME - %button.navbar-toggler{"aria-controls" => "navbarSupportedContent", "aria-expanded" => "false", "aria-label" => "Toggle navigation", "data-bs-target" => "#navbarSupportedContent", "data-bs-toggle" => "collapse", :type => "button"} - %span.navbar-toggler-icon - - .collapse.navbar-collapse#navbarSupportedContent - %ul.navbar-nav.me-auto.mb-2.mb-lg-0 - - if user_signed_in? - %li.nav-item - %a.nav-link{ href: edit_user_registration_path } - Edit Profile - %li.nav-item - = link_to destroy_user_session_path, method: :delete, class: 'nav-link' do - Log Out - - - else - %li.nav-item - %a.nav-link{ href: new_user_session_path } - Login - - .container - .row - .col-4.col-md-4 - .col-4.col-md-4 - = bootstrap_flash - .col-4.col-md-4 - .row - .col-1.col-md-1 - .col-10.col-md-10 - = yield - .col-1.col-md-1 - - #footer - .container - %p.muted.text-center#footer-text - Crafted with ♡ diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 51d40e0c..103d8e0c 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -4,8 +4,8 @@ %meta{:content => "text/html; charset=UTF-8", "http-equiv" => "Content-Type"}/ %meta{:content => "width=device-width,initial-scale=1", :name => "viewport"}/ %meta{:content => "yes", :name => "apple-mobile-web-app-capable"}/ - %meta{:content => "#{stripe_publishable_api_key}", :name => "stripe-key"}/ + %link{:href => "/icons/nav-bar-logo.png", :rel => "shortcut icon", :type => "image/png"}/ %title = PRODUCT_NAME @@ -18,63 +18,15 @@ = yield :heads %link{:href => "/manifest.json", :rel => "manifest"}/ - %link{:href => "/icon.png", :rel => "icon", :type => "image/png"}/ - %link{:href => "/icon.svg", :rel => "icon", :type => "image/svg+xml"}/ - %link{:href => "/icon.png", :rel => "apple-touch-icon"}/ - - // Bootstrap CSS - %link{:crossorigin => "anonymous", :href => "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css", :integrity => "sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH", :rel => "stylesheet"}/ - - // Optional theme - %link{:crossorigin => "anonymous", :href => "https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap-theme.min.css", :integrity => "sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp", :rel => "stylesheet"}/ - - // Bootstrap Icons - %link{:crossorigin => "anonymous", :href => "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css", :rel => "stylesheet"}/ - - // Flatpickr CSS - %link{:crossorigin => "anonymous", :href => "https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css", :rel => "stylesheet"}/ - - // Flatpickr JS - %script{:crossorigin => "anonymous", :src => "https://cdn.jsdelivr.net/npm/flatpickr"} - - // Popover JS - %script{:crossorigin => "anonymous", :integrity => "sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r", :src => "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"} - - // Bootstrap Minified JS - %script{:crossorigin => "anonymous", :integrity => "sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy", :src => "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"} // Stripe %script{:src => "https://js.stripe.com/v3/"} - %meta{ name: "turbo-visit-control", content: "reload" } - = stylesheet_link_tag "application", "data-turbo-track": "reload" = javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %body - %nav.navbar.navbar-expand-lg.border-bottom.border-body - .container-fluid - %a.navbar-brand{ href: root_path } - = PRODUCT_NAME - %button.navbar-toggler{"aria-controls" => "navbarSupportedContent", "aria-expanded" => "false", "aria-label" => "Toggle navigation", "data-bs-target" => "#navbarSupportedContent", "data-bs-toggle" => "collapse", :type => "button"} - %span.navbar-toggler-icon - - #navbarSupportedContent.collapse.navbar-collapse - %ul.navbar-nav.me-auto.mb-2.mb-lg-0 - - if user_signed_in? - %li.nav-item - %a.nav-link{ href: edit_user_registration_path } - Your Profile - %li.nav-item - = link_to destroy_user_session_path, data: { "turbo-method": :delete }, class: 'nav-link' do - Logout - - - else - %li.nav-item - %a.nav-link{ href: new_user_session_path } - Login - - + = render partial: "shared/navigation" .container .row .col-3.col-md-3 diff --git a/app/views/shared/_navigation.html.haml b/app/views/shared/_navigation.html.haml new file mode 100644 index 00000000..a6c59b03 --- /dev/null +++ b/app/views/shared/_navigation.html.haml @@ -0,0 +1,31 @@ +%nav.navbar.navbar-expand-lg.border-bottom.border-body.navbar-dark + %a.navbar-brand{ href: root_path } + = image_tag "/icons/nav-bar-logo.png", size: "80x80" + %button.navbar-toggler{"aria-controls" => "navbarSupportedContent", "aria-expanded" => "false", "aria-label" => "Toggle navigation", "data-bs-target" => "#navbarSupportedContent", "data-bs-toggle" => "collapse", :type => "button"} + %span.navbar-toggler-icon + + #navbarSupportedContent.collapse.navbar-collapse + %ul.navbar-nav.me-auto.mb-2.mb-lg-0 + - if user_signed_in? + %li.nav-item + %a.nav-link{ href: events_path } + Events + + %li.nav-item + %a.nav-link{ href: edit_user_registration_path } + Your Profile + + %li.nav-item + = link_to destroy_user_session_path, data: { "turbo-method": :delete }, class: 'nav-link' do + Logout + - else + %li.nav-item + %a.nav-link{ href: new_user_session_path } + Login + + - if User.has_devise_module?(:registerable) && controller_name != 'registrations' + %li.nav-item + %a.nav-link{ href: new_user_registration_path } + Register + + diff --git a/app/views/shared/_pay.html.haml b/app/views/shared/_pay.html.haml index 6482d7e9..00eb14d3 100644 --- a/app/views/shared/_pay.html.haml +++ b/app/views/shared/_pay.html.haml @@ -1,6 +1,7 @@ -%form#payment-form{"data-controller" => "checkout", "data-stripe-client-secret" => "#{@payment_intent_client_secret}", "data-stripe-publishable-key" => "#{stripe_publishable_api_key}" +%form#payment-form{"data-controller" => "checkout", "data-stripe-client-secret" => "#{@payment_intent_client_secret}", "data-stripe-publishable-key" => "#{stripe_publishable_api_key}"} #payment-message.hidden #payment-element - %button#submit.btn.btn-success.mt-3 + %button.submit.btn.btn-success.mt-3s #spinner.spinner.hidden - %span#button-text Pay now + %span.button-text + Pay now diff --git a/app/views/ticket_requests/new.html.haml b/app/views/ticket_requests/new.html.haml index 35a737c2..54cb162c 100644 --- a/app/views/ticket_requests/new.html.haml +++ b/app/views/ticket_requests/new.html.haml @@ -1,7 +1,7 @@ - if @event.ticket_requests_open? %h1 Request Tickets for #{@event.name} - = image_tag('fnf.png', class: 'pull-right', width: 100) + = image_tag('logos/fnf-transparent.png', class: 'pull-right', width: 100) %p.lead %i.icon-calendar diff --git a/app/views/users/confirmations/new.html.haml b/app/views/users/confirmations/new.html.haml deleted file mode 100644 index e0145c60..00000000 --- a/app/views/users/confirmations/new.html.haml +++ /dev/null @@ -1,17 +0,0 @@ -= form_for resource, as: resource_name, url: confirmation_path(resource_name), - html: { method: :post } do |f| - = render 'shared/form_errors', resource: resource - - %h2 Didn't receive a confirmation email? - - %p.lead - Fill out the form below with the - email you used to register an account and we'll resend the confirmation email. - - = f.label :email - = f.email_field :email, autofocus: true - - .actions - = f.submit 'Resend email', class: 'btn btn-primary btn-large' - -= render 'users/shared/links' diff --git a/app/views/users/mailer/confirmation_instructions.html.haml b/app/views/users/mailer/confirmation_instructions.html.haml deleted file mode 100644 index 1d076074..00000000 --- a/app/views/users/mailer/confirmation_instructions.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -%p - Hi #{@resource.first_name}! - -%p - Thanks for registering an account. You can confirm your email through the following link: - -%p - = link_to 'Confirm my account', - confirmation_url(@resource, confirmation_token: @resource.confirmation_token) diff --git a/app/views/users/mailer/reset_password_instructions.html.haml b/app/views/users/mailer/reset_password_instructions.html.haml deleted file mode 100644 index 68411989..00000000 --- a/app/views/users/mailer/reset_password_instructions.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -%p - Hi #{@resource.first_name}, - -%p - Someone (hopefully you) has requested a link to change your password. - You can reset your password by following the link below: - -%p - = link_to 'Change my password', - edit_password_url(@resource, reset_password_token: @token) - -%p - If you didn't request this, please ignore this email. - Your password won't change until you access the link above and create a new one. diff --git a/app/views/users/mailer/unlock_instructions.html.haml b/app/views/users/mailer/unlock_instructions.html.haml deleted file mode 100644 index 6976c4c9..00000000 --- a/app/views/users/mailer/unlock_instructions.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -%p Hello #{@resource.first_name}! - -%p - Your account has been locked due to an excessive number of unsuccessful attempts to sign in. - -%p - You can follows the link below to unlock your account: - -%p= link_to 'Unlock my account', - unlock_url(@resource, unlock_token: @resource.unlock_token) diff --git a/app/views/users/passwords/edit.html.haml b/app/views/users/passwords/edit.html.haml deleted file mode 100644 index 47ae4852..00000000 --- a/app/views/users/passwords/edit.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -= form_for resource, as: resource_name, url: password_path(resource_name), - html: { method: :put } do |f| - = render 'shared/form_errors', resource: resource - - %h2 Change your Password - - = f.hidden_field :reset_password_token - - = f.label :password, 'New password' - = f.password_field :password, autofocus: true - - .actions - = f.submit 'Change my password', class: 'btn btn-primary btn-large' - -= render 'users/shared/links' diff --git a/app/views/users/passwords/new.html.haml b/app/views/users/passwords/new.html.haml deleted file mode 100644 index d2cc947d..00000000 --- a/app/views/users/passwords/new.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -= form_for resource, as: resource_name, url: password_path(resource_name), - html: { method: :post } do |f| - = render 'shared/form_errors', resource: resource - - %h2 Forgot your password? - - %p.lead - No problem. Enter your email and we'll send you password reset instructions. - - = f.label :email - = f.email_field :email, autofocus: true, placeholder: 'email@example.com' - - .actions - = f.submit 'Send me instructions', class: 'btn btn-primary btn-large' - -= render 'users/shared/links' diff --git a/app/views/users/registrations/edit.html.haml b/app/views/users/registrations/edit.html.haml deleted file mode 100644 index ff32edd7..00000000 --- a/app/views/users/registrations/edit.html.haml +++ /dev/null @@ -1,36 +0,0 @@ -= form_for resource, as: resource_name, url: registration_path(resource_name), - html: { method: :put } do |f| - = render 'shared/form_errors', resource: resource - - %h2 Account Settings - - = f.label :name - = f.text_field :name, maxlength: User::MAX_NAME_LENGTH, required: true, - class: 'input-xlarge' - - = f.label :email - = f.email_field :email, placeholder: 'email@example.com', - maxlength: User::MAX_EMAIL_LENGTH, required: true, autofocus: true, - class: 'input-xlarge' - - - if devise_mapping.confirmable? && resource.pending_reconfirmation? - Currently waiting confirmation for: - %strong= resource.unconfirmed_email - - = f.label :password do - Password - %p.muted Leave blank to keep the same - = f.password_field :password, - required: true, autocomplete: 'off', - maxlength: User::MAX_PASSWORD_LENGTH, class: 'input-xlarge' - - = f.label :current_password do - Current password - %p.muted Enter your current password to confirm your changes - = f.password_field :current_password, maxlength: User::MAX_PASSWORD_LENGTH, - required: true, class: 'input-xlarge' - - .actions - = f.submit 'Update', class: 'btn btn-primary btn-large' - -= link_to 'Back', :back diff --git a/app/views/users/registrations/new.html.haml b/app/views/users/registrations/new.html.haml deleted file mode 100644 index e106059e..00000000 --- a/app/views/users/registrations/new.html.haml +++ /dev/null @@ -1,28 +0,0 @@ -= form_for resource, as: resource_name, url: registration_path(resource_name) do |f| - = render 'shared/form_errors', resource: resource - - %h2 Register an account - - %p.lead - Having an account allows you to request tickets and receive status - updates on your requests. - - = f.label :name, 'Full Name' - = f.text_field :name, autofocus: true, placeholder: 'First and last name', - maxlength: User::MAX_NAME_LENGTH, required: true, - class: 'input-xlarge' - - = f.label :email - = f.email_field :email, placeholder: 'email@example.com', - maxlength: User::MAX_EMAIL_LENGTH, required: true, - class: 'input-xlarge' - - = f.label :password - = f.password_field :password, placeholder: 'Must be at least 8 characters', - required: true, - maxlength: User::MAX_PASSWORD_LENGTH, class: 'input-xlarge' - - .actions - = f.submit 'Register', class: 'btn btn-primary btn-large' - -= render 'users/shared/links' diff --git a/app/views/users/sessions/new.html.haml b/app/views/users/sessions/new.html.haml deleted file mode 100644 index 234f55a9..00000000 --- a/app/views/users/sessions/new.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -= form_for resource, as: resource_name, url: session_path(resource_name) do |f| - - %h2 Sign In - - = f.label :email, style: 'width: 100px;' - = f.email_field :email, autofocus: true, placeholder: 'email@example.com', style: 'width: 200px;' - = f.label :password, style: 'width: 100px;' - = f.password_field :password, style: 'width: 200px;' - - - if devise_mapping.rememberable? - = f.label :remember_me, class: 'checkbox' do - = f.check_box :remember_me - Remember me - = f.submit 'Sign in', class: 'btn btn-primary btn-large' - -= render 'users/shared/links' diff --git a/app/views/users/shared/_links.html.haml b/app/views/users/shared/_links.html.haml deleted file mode 100644 index 671b0352..00000000 --- a/app/views/users/shared/_links.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -%ul.shared-user-links - - if controller_name != 'sessions' - %li= link_to 'Sign in', new_session_path(resource_name) - - - if devise_mapping.registerable? && controller_name != 'registrations' - %li= link_to 'Register', new_registration_path(resource_name) - - - if devise_mapping.recoverable? && controller_name != 'passwords' - %li= link_to 'Forgot your password?', new_password_path(resource_name) - - - if devise_mapping.confirmable? && controller_name != 'confirmations' - %li= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) - - - if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' - %li= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) diff --git a/app/views/users/unlocks/new.html.haml b/app/views/users/unlocks/new.html.haml deleted file mode 100644 index eb0a45eb..00000000 --- a/app/views/users/unlocks/new.html.haml +++ /dev/null @@ -1,16 +0,0 @@ -= form_for resource, as: resource_name, url: unlock_path(resource_name), - html: { method: :post } do |f| - = render 'shared/form_errors', resource: resource - - %h2 Were you told that your account was locked? - - %p.lead - Fill out the form below and we'll resend you instructions for unlocking your account. - - = f.label :email - = f.email_field :email, autofocus: true, placeholder: 'email@example.com' - - .actions - = f.submit 'Resend email', class: 'btn btn-primary btn-large' - -= render 'users/shared/links' diff --git a/config/environments/development.rb b/config/environments/development.rb index 4646801c..fc8c823b 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -79,4 +79,7 @@ config.action_mailer.default_url_options = { host: 'localhost:3000' } config.action_mailer.raise_delivery_errors = false config.action_mailer.delivery_method = :test + + config.hosts = %w[localhost 127.0.0.1 127.0.0.1:3000 127.0.0.1:5000] + config.hosts << 'tickets-local.fnf.org:5000' end diff --git a/config/initializers/country_select.rb b/config/initializers/country_select.rb index 8fdc8a6b..ff3efae8 100644 --- a/config/initializers/country_select.rb +++ b/config/initializers/country_select.rb @@ -3,4 +3,4 @@ require 'country_select' CountrySelect::DEFAULTS[:only] = %w[US GB] -CountrySelect::DEFAULTS[:priority_countries] = %w[US GB] +# CountrySelect::DEFAULTS[:priority_countries] = %w[US GB] diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 433f05f9..c8db3126 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -1,15 +1,36 @@ # frozen_string_literal: true +# Assuming you have not yet modified this file, each configuration option below +# is set to its default value. Note that some are commented out while others +# are not: uncommented lines are intended to protect your configuration from +# breaking changes in upgrades (i.e., in the event that future versions of +# Devise change the default values for those options). +# # Use this hook to configure devise mailer, warden hooks and so forth. # Many of these configuration options can be set straight in your model. Devise.setup do |config| + # The secret key used by Devise. Devise uses this key to generate + # random tokens. Changing this key will render invalid all existing + # 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 = 'e65998c860480884efbb578c3fa25df5dabc2bfb323bc8609248df8d9f857f1b80914fe7e58dd1478e4e97021eaa962ba894bf0901537b53f0a39d0842580347' + + # ==> Controller configuration + # Configure the parent class to the devise controllers. + # config.parent_controller = 'DeviseController' + # ==> Mailer Configuration # Configure the e-mail address which will be shown in Devise::Mailer, - # note that it will be overwritten if you use your own mailer class with default "from" parameter. + # note that it will be overwritten if you use your own mailer class + # with default "from" parameter. config.mailer_sender = 'FnF Tickets ' # Configure the class responsible to send e-mails. - # config.mailer = "Devise::Mailer" + # config.mailer = 'Devise::Mailer' + + # Configure the parent class responsible to send e-mails. + # config.parent_mailer = 'ActionMailer::Base' # ==> ORM configuration # Load and configure the ORM. Supports :active_record (default) and @@ -25,7 +46,7 @@ # session. If you need permissions, you should implement that in a before filter. # You can also supply a hash where the value is a boolean determining whether # or not authentication should be aborted when the value is not present. - # config.authentication_keys = [ :email ] + # config.authentication_keys = [:email] # Configure parameters from the request object used for authentication. Each entry # given should be a request method and it will automatically be passed to the @@ -50,17 +71,21 @@ # enable it only for database (email + password) authentication. # config.params_authenticatable = true - # Tell if authentication through HTTP Basic Auth is enabled. False by default. + # Tell if authentication through HTTP Auth is enabled. False by default. # It can be set to an array that will enable http authentication only for the - # given strategies, for example, `config.http_authenticatable = [:token]` will - # enable it only for token authentication. + # given strategies, for example, `config.http_authenticatable = [:database]` will + # enable it only for database authentication. + # For API-only applications to support authentication "out-of-the-box", you will likely want to + # enable this with :database unless you are using a custom strategy. + # The supported strategies are: + # :database = Support basic authentication with authentication key + password # config.http_authenticatable = false - # If http headers should be returned for AJAX requests. True by default. + # If 401 status code should be returned for AJAX requests. True by default. # config.http_authenticatable_on_xhr = true - # The realm used in Http Basic Authentication. "Application" by default. - # config.http_authentication_realm = "Application" + # The realm used in Http Basic Authentication. 'Application' by default. + # config.http_authentication_realm = 'Application' # It will change confirmation, password recovery and other workflows # to behave the same regardless if the e-mail provided was right or wrong. @@ -68,31 +93,57 @@ # config.paranoid = true # By default Devise will store the user in session. You can skip storage for - # :http_auth and :token_auth by adding those symbols to the array below. + # particular strategies by setting this option. # Notice that if you are skipping storage for all authentication paths, you # may want to disable generating routes to Devise's sessions controller by - # passing :skip => :sessions to `devise_for` in your config/routes.rb + # passing skip: :sessions to `devise_for` in your config/routes.rb config.skip_session_storage = [:http_auth] + # By default, Devise cleans up the CSRF token on authentication to + # avoid CSRF token fixation attacks. This means that, when using AJAX + # requests for sign in and sign up, you need to get a new CSRF token + # from the server. You can disable this option at your own risk. + # config.clean_up_csrf_token_on_authentication = true + + # When false, Devise will not attempt to reload routes on eager load. + # This can reduce the time taken to boot the app but if your application + # requires the Devise mappings to be loaded during boot time the application + # won't boot properly. + # config.reload_routes = true + # ==> Configuration for :database_authenticatable - # For bcrypt, this is the cost for hashing the password and defaults to 10. If - # using other encryptors, it sets how many times you want the password re-encrypted. + # For bcrypt, this is the cost for hashing the password and defaults to 12. If + # using other algorithms, it sets how many times you want the password to be hashed. + # The number of stretches used for generating the hashed password are stored + # with the hashed password. This allows you to change the stretches without + # invalidating existing passwords. # # Limiting the stretches to just one in testing will increase the performance of # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use - # a value less than 10 in other environments. - config.stretches = Rails.env.test? ? 1 : 10 + # a value less than 10 in other environments. Note that, for bcrypt (the default + # algorithm), the cost increases exponentially with the number of stretches (e.g. + # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). + config.stretches = Rails.env.test? ? 1 : 12 - # Setup a pepper to generate the encrypted password. - # config.pepper = "8637e2b1a4751e2d8368f014163b7d7702f4600088d505cb7f4e27b44028ef3e28d2e06059502a2c8f662b8b3138e62c17e64142932aa329e4b4cc331d57b4ec" + # Set up a pepper to generate the hashed password. + # config.pepper = 'efa82e8d45e1e8f30bb0280d59c3952046739dae62e54a85e6aba2cc9e4ebd8665cd3b0642ee544958e08768d911302e545a4db72578ac6ea2ef4585eb93e7b4' + + # Send a notification to the original email when the user's email is changed. + # config.send_email_changed_notification = false + + # Send a notification email when the user's password is changed. + # config.send_password_change_notification = false # ==> Configuration for :confirmable # A period that the user is allowed to access the website even without - # confirming his account. For instance, if set to 2.days, the user will be - # able to access the website for two days without confirming his account, - # access will be blocked just in the third day. Default is 0.days, meaning - # the user cannot access the website without confirming his account. - # config.allow_unconfirmed_access_for = 24.hours + # confirming their account. For instance, if set to 2.days, the user will be + # able to access the website for two days without confirming their account, + # access will be blocked just in the third day. + # You can also set it to nil, which will allow the user to access the website + # without confirming their account. + # Default is 0.days, meaning the user cannot access the website without + # confirming their account. + # config.allow_unconfirmed_access_for = 2.days # A period that the user is allowed to confirm their account before their # token becomes invalid. For example, if set to 3.days, the user can confirm @@ -104,41 +155,41 @@ # If true, requires any email changes to be confirmed (exactly the same way as # initial account confirmation) to be applied. Requires additional unconfirmed_email - # db field (see migrations). Until confirmed new email is stored in - # unconfirmed email column, and copied to email column on successful confirmation. - # config.reconfirmable = true + # db field (see migrations). Until confirmed, new email is stored in + # unconfirmed_email column, and copied to email column on successful confirmation. + config.reconfirmable = true # Defines which key will be used when confirming an account - # config.confirmation_keys = [ :email ] + # config.confirmation_keys = [:email] # ==> Configuration for :rememberable # The time the user will be remembered without asking for credentials again. # config.remember_for = 2.weeks + # Invalidates all the remember me tokens when the user signs out. + config.expire_all_remember_me_on_sign_out = true + # If true, extends the user's remember period when remembered via cookie. # config.extend_remember_period = false # Options to be passed to the created cookie. For instance, you can set - # :secure => true in order to force SSL only cookies. + # secure: true in order to force SSL only cookies. # config.rememberable_options = {} # ==> Configuration for :validatable - # Range for password length. Default is 8..128. - config.password_length = 5..128 + # Range for password length. + config.password_length = 6..128 # Email regex used to validate email formats. It simply asserts that - # an one (and only one) @ exists in the given string. This is mainly + # one (and only one) @ exists in the given string. This is mainly # to give user feedback and not to assert the e-mail validity. - # config.email_regexp = /\A[^@]+@[^@]+\z/ + config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ # ==> Configuration for :timeoutable # The time you want to timeout the user session without activity. After this # time the user will be asked for credentials again. Default is 30 minutes. # config.timeout_in = 30.minutes - # If true, expires auth token on session timeout. - # config.expire_auth_token_on_timeout = false - # ==> Configuration for :lockable # Defines which strategy will be used to lock an account. # :failed_attempts = Locks an account after a number of failed attempts to sign in. @@ -146,7 +197,7 @@ config.lock_strategy = :failed_attempts # Defines which key will be used when locking and unlocking an account - # config.unlock_keys = [ :email ] + # config.unlock_keys = [:email] # Defines which strategy will be used to unlock an account. # :email = Sends an unlock link to the user email @@ -162,33 +213,38 @@ # Time interval to unlock the account if :time is enabled as unlock_strategy. config.unlock_in = 1.hour + # Warn on the last attempt before the account is locked. + # config.last_attempt_warning = true + # ==> Configuration for :recoverable # # Defines which key will be used when recovering the password for an account - # config.reset_password_keys = [ :email ] + # config.reset_password_keys = [:email] # Time interval you can reset your password with a reset password key. # Don't put a too small interval or your users won't have the time to # change their passwords. config.reset_password_within = 6.hours + # When set to false, does not sign a user in automatically after their password is + # reset. Defaults to true, so a user is signed in automatically after a reset. + # config.sign_in_after_reset_password = true + # ==> Configuration for :encryptable - # Allow you to use another encryption algorithm besides bcrypt (default). You can use - # :sha1, :sha512 or encryptors from others authentication tools as :clearance_sha1, - # :authlogic_sha512 (then you should set stretches above to 20 for default behavior) - # and :restful_authentication_sha1 (then you should set stretches to 10, and copy - # REST_AUTH_SITE_KEY to pepper) + # Allow you to use another hashing or encryption algorithm besides bcrypt (default). + # You can use :sha1, :sha512 or algorithms from others authentication tools as + # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20 + # for default behavior) and :restful_authentication_sha1 (then you should set + # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). + # + # Require the `devise-encryptable` gem when using anything other than bcrypt # config.encryptor = :sha512 - # ==> Configuration for :token_authenticatable - # Defines name of the authentication token params key - # config.token_authentication_key = :auth_token - # ==> Scopes configuration # Turn scoped views on. Before rendering "sessions/new", it will first check for # "users/sessions/new". It's turned off by default because it's slower if you # are using only default views. - config.scoped_views = true + # config.scoped_views = false # Configure the default scope given to Warden. By default it's the first # devise role declared in your routes (usually :user). @@ -200,14 +256,14 @@ # ==> Navigation configuration # Lists the formats that should be treated as navigational. Formats like - # :html, should redirect to the sign in page when the user does not have + # :html should redirect to the sign in page when the user does not have # access, but formats like :xml or :json, should return 401. # # If you have any extra navigational formats, like :iphone or :mobile, you # should add them to the navigational formats lists. # # The "*/*" below is required to match Internet Explorer requests. - # config.navigational_formats = ["*/*", :html] + # config.navigational_formats = ['*/*', :html, :turbo_stream] # The default HTTP method used to sign out a resource. Default is :delete. config.sign_out_via = :delete @@ -215,7 +271,7 @@ # ==> OmniAuth # Add a new OmniAuth provider. Check the wiki for more information on setting # up on your models and hooks. - # config.omniauth :github, 'APP_ID', 'APP_SECRET', :scope => 'user,public_repo' + # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or @@ -223,7 +279,7 @@ # # config.warden do |manager| # manager.intercept_401 = false - # manager.default_strategies(:scope => :user).unshift :some_external_strategy + # manager.default_strategies(scope: :user).unshift :some_external_strategy # end # ==> Mountable engine configurations @@ -231,19 +287,27 @@ # is mountable, there are some extra configurations to be taken into account. # The following options are available, assuming the engine is mounted as: # - # mount MyEngine, at: "/my_engine" + # mount MyEngine, at: '/my_engine' # # The router that invoked `devise_for`, in the example above, would be: # config.router_name = :my_engine # - # When using omniauth, Devise cannot automatically set Omniauth path, + # When using OmniAuth, Devise cannot automatically set OmniAuth path, # so you need to do it manually. For the users scope, it would be: - # config.omniauth_path_prefix = "/my_engine/users/auth" - - # ==> Configuration for Devise with Turbo/Hotwire - # - # As per https://github.com/heartcombo/devise/wiki/How-To:-Upgrade-to-Devise-4.9.0-%5BHotwire-Turbo-integration%5D - # + # config.omniauth_path_prefix = '/my_engine/users/auth' + + # ==> Hotwire/Turbo configuration + # When using Devise with Hotwire/Turbo, the http status for error responses + # and some redirects must match the following. The default in Devise for existing + # apps is `200 OK` and `302 Found` respectively, but new apps are generated with + # these new defaults that match Hotwire/Turbo behavior. + # Note: These might become the new default in future versions of Devise. config.responder.error_status = :unprocessable_entity config.responder.redirect_status = :see_other + + # ==> Configuration for :registerable + + # When set to false, does not sign a user in automatically after their password is + # changed. Defaults to true, so a user is signed in automatically after changing a password. + # config.sign_in_after_change_password = true end diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml new file mode 100644 index 00000000..260e1c4b --- /dev/null +++ b/config/locales/devise.en.yml @@ -0,0 +1,65 @@ +# Additional translations at https://github.com/heartcombo/devise/wiki/I18n + +en: + devise: + confirmations: + confirmed: "Your email address has been successfully confirmed." + send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." + failure: + already_authenticated: "You are already signed in." + inactive: "Your account is not activated yet." + invalid: "Invalid %{authentication_keys} or password." + locked: "Your account is locked." + last_attempt: "You have one more attempt before your account is locked." + not_found_in_database: "Invalid %{authentication_keys} or password." + timeout: "Your session expired. Please sign in again to continue." + unauthenticated: "You need to sign in or sign up before continuing." + unconfirmed: "You have to confirm your email address before continuing." + mailer: + confirmation_instructions: + subject: "Confirmation instructions" + reset_password_instructions: + subject: "Reset password instructions" + unlock_instructions: + subject: "Unlock instructions" + email_changed: + subject: "Email Changed" + password_change: + subject: "Password Changed" + omniauth_callbacks: + failure: "Could not authenticate you from %{kind} because \"%{reason}\"." + success: "Successfully authenticated from %{kind} account." + passwords: + no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." + send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." + send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." + updated: "Your password has been changed successfully. You are now signed in." + updated_not_active: "Your password has been changed successfully." + registrations: + destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." + signed_up: "Welcome! You have signed up successfully." + signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." + signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." + signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." + update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." + updated: "Your account has been updated successfully." + updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." + sessions: + signed_in: "Signed in successfully." + signed_out: "Signed out successfully." + already_signed_out: "Signed out successfully." + unlocks: + send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." + send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." + unlocked: "Your account has been unlocked successfully. Please sign in to continue." + errors: + messages: + already_confirmed: "was already confirmed, please try signing in" + confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" + expired: "has expired, please request a new one" + not_found: "not found" + not_locked: "was not locked" + not_saved: + one: "1 error prohibited this %{resource} from being saved:" + other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/db/migrate/20240418004856_split_users_name_into_first_and_last.rb b/db/migrate/20240418004856_split_users_name_into_first_and_last.rb new file mode 100644 index 00000000..f494db87 --- /dev/null +++ b/db/migrate/20240418004856_split_users_name_into_first_and_last.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class SplitUsersNameIntoFirstAndLast < ActiveRecord::Migration[7.1] + # rubocop: disable Rails/BulkChangeTable + def up + add_column :users, :first, :text + add_column :users, :last, :text + + User.find_each do |user| + next if user.name.blank? + + name_components = user.name.gsub(/^(Dr\.?|Esq\.?)\s?/, '').split(/\s+/) + user.first = name_components.shift + user.last = name_components.join(' ') + execute "update users set first = '#{user.first}', last = '#{user.last}' where id = #{user.id}" + end + end + + def down + remove_column(:users, :first) + remove_column(:users, :last) + end + # rubocop: enable Rails/BulkChangeTable +end diff --git a/db/structure.sql b/db/structure.sql index a0c0cd6d..bcc364d4 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -434,7 +434,9 @@ CREATE TABLE public.users ( created_at timestamp(6) without time zone NOT NULL, updated_at timestamp(6) without time zone NOT NULL, name character varying(70) NOT NULL, - authentication_token character varying(64) + authentication_token character varying(64), + first text, + last text ); @@ -729,6 +731,7 @@ CREATE UNIQUE INDEX index_users_on_unlock_token ON public.users USING btree (unl SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20240418004856'), ('20240311182346'), ('20180527021019'), ('20160611234315'), diff --git a/package.json b/package.json index 769809f5..16770d5c 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "build:css": "yarn build:css:compile && yarn build:css:prefix", "build:css:compile": "sass ./app/assets/stylesheets/application.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules", "build:css:prefix": "postcss ./app/assets/builds/application.css --use=autoprefixer --output=./app/assets/builds/application.css", - "build:js": "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets", + "build:js": "esbuild app/javascript/*.* --bundle --sourcemap --ignore-annotations --format=esm --outdir=app/assets/builds --public-path=/assets", "dev": "tsc-watch --noClear -p tsconfig.json --onSuccess \"yarn build:js\" --onFailure \"yarn failure:js\"", "failure:js": "rm ./app/assets/builds/application.js && rm ./app/assets/builds/application.js.map", "watch:css": "nodemon --watch ./app/assets/stylesheets/ --ext scss --exec \"yarn build:css\"", diff --git a/public/icon.png b/public/icon.png deleted file mode 100644 index f3b5abcb..00000000 Binary files a/public/icon.png and /dev/null differ diff --git a/public/icon.svg b/public/icon.svg deleted file mode 100644 index 78307ccd..00000000 --- a/public/icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/public/icons/android-chrome-192x192.png b/public/icons/android-chrome-192x192.png new file mode 100644 index 00000000..a98b0040 Binary files /dev/null and b/public/icons/android-chrome-192x192.png differ diff --git a/public/icons/android-chrome-512x512.png b/public/icons/android-chrome-512x512.png new file mode 100644 index 00000000..a97eb40d Binary files /dev/null and b/public/icons/android-chrome-512x512.png differ diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 00000000..9b55e211 Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/favicon-16x16.png b/public/icons/favicon-16x16.png new file mode 100644 index 00000000..3b4b0b9c Binary files /dev/null and b/public/icons/favicon-16x16.png differ diff --git a/public/icons/favicon-32x32.png b/public/icons/favicon-32x32.png new file mode 100644 index 00000000..2a65018b Binary files /dev/null and b/public/icons/favicon-32x32.png differ diff --git a/public/icons/favicon.ico b/public/icons/favicon.ico new file mode 100644 index 00000000..68bf3ddb Binary files /dev/null and b/public/icons/favicon.ico differ diff --git a/public/icons/nav-bar-logo.png b/public/icons/nav-bar-logo.png new file mode 100644 index 00000000..67e5baf3 Binary files /dev/null and b/public/icons/nav-bar-logo.png differ diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 00000000..2bf0579f --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "TicketBooth", + "short_name": "TB", + "icons": [ + { + "src": "/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#000", + "display": "standalone" +} diff --git a/spec/factories/user.rb b/spec/factories/user.rb index 64e399c1..353426aa 100644 --- a/spec/factories/user.rb +++ b/spec/factories/user.rb @@ -2,7 +2,9 @@ FactoryBot.define do factory :user do - name { Faker::Name.name } + first { Faker::Name.first_name } + last { Faker::Name.last_name } + name { "#{first} #{last}" } email { Faker::Internet.email } password { Faker::Internet.password(min_length: 8) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 83b86f8a..b2b48ff1 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -14,8 +14,10 @@ # email :string not null # encrypted_password :string not null # failed_attempts :integer default(0) +# first :text +# last :text # last_sign_in_at :datetime -# last_sign_in_ip :string +# last_sign_in_ip :string`` # locked_at :datetime # name :string(70) not null # remember_created_at :datetime @@ -33,71 +35,71 @@ # index_users_on_email (email) UNIQUE # index_users_on_reset_password_token (reset_password_token) UNIQUE # index_users_on_unlock_token (unlock_token) UNIQUE +# require 'rails_helper' describe User do + let(:last) { nil } + let(:first) { nil } + it 'has a valid factory' do expect(build(:user)).to be_valid end describe 'validation' do - describe '#name' do - subject(:user) { build(:user, name:) } + describe '#first and #last' do + subject(:user) { build(:user, first:, last:) } context 'when not present' do - let(:name) { nil } - it { is_expected.not_to be_valid } end context 'when empty' do - let(:name) { '' } + let(:first) { '' } + let(:last) { '' } it { is_expected.not_to be_valid } end - context 'when longer than 70 characters' do - let(:name) { 'x' * 100 } + context 'when only first name exists' do + let(:first) { Faker::Name.first_name } it { is_expected.not_to be_valid } end - context 'when just a first name' do - let(:name) { 'John' } + context 'when only the last name exists' do + let(:last) { 'Smith' } it { is_expected.not_to be_valid } end - context 'when both first and last name' do - let(:name) { 'John Smith' } - - it { is_expected.to be_valid } - end - context 'when first, middle and last name' do - let(:name) { 'John Jacob Smith' } + let(:first) { 'John Jacob' } + let(:last) { 'Smith' } it { is_expected.to be_valid } end context 'when multiples spaces are between names' do - let(:name) { 'John Smith' } + let(:first) { 'John Jacob' } + let(:last) { 'Smith' } it 'condenses multiple spaces into a single space' do user.valid? - user.name.should == 'John Smith' + user.name.should == 'John Jacob Smith' end it { is_expected.to be_valid } end context 'when leading or trailing whitespace exists' do - let(:name) { ' John Smith ' } + let(:first) { ' John Jacob ' } + let(:last) { 'Smith' } it 'removes the surrounding whitespace' do user.valid? - user.name.should == 'John Smith' + user.name.should == 'John Jacob Smith' end it { is_expected.to be_valid } @@ -140,12 +142,12 @@ end describe '#first_name' do - let(:first_name) { 'John' } - let(:last_name) { 'Smith' } - let(:user) { create(:user, name: [first_name, last_name].join(' ')) } + let(:first) { 'John' } + let(:last) { 'Smith' } + let(:user) { create(:user, first:, last:) } it 'returns the first name' do - user.first_name.should == first_name + user.name.should == "#{first} #{last}" end end