diff --git a/app/assets/stylesheets/fizzy-menu.css b/app/assets/stylesheets/fizzy-menu.css index cf55a3630..1d768e9d6 100644 --- a/app/assets/stylesheets/fizzy-menu.css +++ b/app/assets/stylesheets/fizzy-menu.css @@ -1,4 +1,16 @@ @layer components { + .boxcar-header-logo { + color: var(--color-ink); + inline-size: auto; + margin-block-start: 0.1em; + + img { + block-size: auto; + inline-size: 1.15em; + margin-inline-end: 0.8ch; + } + } + .input:is(.fizzy-menu-trigger) { --input-background: var(--color-canvas); --input-border-color: transparent; diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index e0ca57536..7d9903996 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -28,6 +28,26 @@ &[readonly] { --focus-ring-size: 0; } + + &[autocomplete='one-time-code'] { + --input-spacing: 0.5em; + + font-family: var(--font-mono); + font-size: var(--text-large); + font-weight: 900; + inline-size: 18ch; + letter-spacing: 1ch; + min-inline-size: 18ch; + text-align: center; + } + + &[type='number'] { + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } } .input--file { diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 3eb820bfb..cea383315 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -19,6 +19,7 @@ .txt-subtle { color: var(--color-ink-medium); } .txt-alert { color: var(--color-marker); } .txt-undecorated { text-decoration: none; } + .txt-underline { text-decoration: underline; } .txt-tight-lines { line-height: 1.2; } .txt-nowrap { white-space: nowrap; } .txt-break { word-break: break-word; } diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 0f936d05b..f1e997503 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -4,13 +4,14 @@ module ApplicationCable def connect super - ApplicationRecord.with_tenant(current_tenant) { set_current_user || reject_unauthorized_connection } + set_current_user || reject_unauthorized_connection end private def set_current_user if session = find_session_by_cookie - self.current_user = session.user + membership = session.identity.memberships.find_by!(tenant: current_tenant) + self.current_user = membership.user if membership.user.active? end end diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb index 5c981a2ba..b67246b80 100644 --- a/app/controllers/account/join_codes_controller.rb +++ b/app/controllers/account/join_codes_controller.rb @@ -1,10 +1,31 @@ class Account::JoinCodesController < ApplicationController + before_action :set_join_code + def show - render svg: RQRCode::QRCode.new(join_url(Account.sole.join_code)).as_svg(viewbox: true, fill: :white, color: :black) + end + + def edit end def update - Account.sole.reset_join_code - redirect_to users_path + if @join_code.update(join_code_params) + redirect_to account_join_code_path + else + render :edit, status: :unprocessable_entity + end end + + def destroy + @join_code.reset + redirect_to account_join_code_path + end + + private + def set_join_code + @join_code = Account::JoinCode.sole + end + + def join_code_params + params.expect account_join_code: [ :usage_limit ] + end end diff --git a/app/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb index 6b08abba2..9acebbae4 100644 --- a/app/controllers/account/settings_controller.rb +++ b/app/controllers/account/settings_controller.rb @@ -3,4 +3,18 @@ class Account::SettingsController < ApplicationController @account = Account.sole @users = User.active.alphabetically end + + def update + if Current.user.can_administer? + Account.sole.update!(account_params) + redirect_to account_settings_path + else + head :forbidden + end + end + + private + def account_params + params.expect account: %i[ name ] + end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 786f80bcf..6c16b1906 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,7 @@ class ApplicationController < ActionController::Base include LoadBalancerRouting, WriterAffinity - include Authentication, Authorization + include Authentication + include Authorization include CurrentRequest, CurrentTimezone, SetPlatform include TurboFlash, ViewTransitions diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 97eeeee9f..f8ed9ec6d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -26,7 +26,6 @@ module Authentication def require_untenanted_access(**options) skip_before_action :require_tenant, **options - skip_before_action :require_authentication, **options before_action :redirect_tenanted_request, **options end end @@ -38,8 +37,7 @@ module Authentication def require_tenant unless ApplicationRecord.current_tenant.present? - set_current_identity_token - render "sessions/login_menu" + redirect_to session_menu_url(script_name: nil) end end @@ -57,7 +55,7 @@ module Authentication Session.find_signed(cookies.signed[:session_token]) end - def request_authentication(untenanted: false) + def request_authentication if ApplicationRecord.current_tenant.present? session[:return_to_after_authenticating] = request.url end @@ -77,30 +75,16 @@ module Authentication redirect_to root_url if ApplicationRecord.current_tenant end - def start_new_session_for(user) - link_identity(user) - user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + def start_new_session_for(identity) + identity.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| set_current_session session end end - def link_identity(user) - token_value = cookies.signed[:identity_token] - token_identity = Identity.find_signed(token_value["id"]) if token_value.present? - identity = user.set_identity(token_identity) - cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax } - end - - def set_current_identity_token - link_identity(Current.user) if cookies.signed[:identity_token].nil? && Current.user.present? - Current.identity_token = Identity::Mock.new(**cookies.signed[:identity_token]) - end - def set_current_session(session) - logger.struct " Authorized User##{session.user.id}", authentication: { user: { id: session.user.id } } + logger.struct " Authorized Identity##{session.identity.id}", authentication: { identity: { id: session.identity.id } } Current.session = session - set_current_identity_token - cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax, path: Account.sole.slug } + cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax } end def terminate_session diff --git a/app/controllers/concerns/authentication/session_lookup.rb b/app/controllers/concerns/authentication/session_lookup.rb deleted file mode 100644 index b180f217f..000000000 --- a/app/controllers/concerns/authentication/session_lookup.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Authentication::SessionLookup - def find_session_by_cookie - if token = cookies.signed[:session_token] - Session.find_by(token: token) - end - end -end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 9744a0465..df48f62f5 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -1,4 +1,21 @@ module Authorization + extend ActiveSupport::Concern + + included do + before_action :ensure_can_access_account, if: -> { ApplicationRecord.current_tenant && Current.session } + end + + class_methods do + def allow_unauthorized_access(**options) + skip_before_action :ensure_can_access_account, **options + end + + def require_access_without_a_user(**options) + skip_before_action :ensure_can_access_account, **options + before_action :redirect_existing_user, **options + end + end + private def ensure_admin head :forbidden unless Current.user.admin? @@ -7,4 +24,18 @@ module Authorization def ensure_staff head :forbidden unless Current.user.staff? end + + def ensure_can_access_account + if Current.membership.blank? + redirect_to session_menu_url(script_name: nil) + elsif Current.user.nil? && Current.membership.join_code.present? + redirect_to new_user_path + elsif !Current.user&.active? + redirect_to unlink_membership_url(script_name: nil, membership_id: Current.membership.signed_id(purpose: :unlinking)) + end + end + + def redirect_existing_user + redirect_to root_path if Current.user + end end diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb new file mode 100644 index 000000000..972420439 --- /dev/null +++ b/app/controllers/join_codes_controller.rb @@ -0,0 +1,32 @@ +class JoinCodesController < ApplicationController + require_untenanted_access + allow_unauthenticated_access + before_action :set_join_code + + def new + @account_name = ApplicationRecord.with_tenant(tenant) { Account.sole.name } + end + + def create + Identity.transaction do + identity = Identity.find_or_create_by(email_address: params.expect(:email_address)) + identity.memberships.create!(tenant: tenant, join_code: code) + identity.send_magic_link + end + + redirect_to session_magic_link_path + end + + private + def set_join_code + @join_code ||= ApplicationRecord.with_tenant(tenant) { Account::JoinCode.active.find_by(code: code) } + end + + def tenant + params.expect(:tenant) + end + + def code + params.expect(:code) + end +end diff --git a/app/controllers/memberships/email_addresses/confirmations_controller.rb b/app/controllers/memberships/email_addresses/confirmations_controller.rb new file mode 100644 index 000000000..8ad43971f --- /dev/null +++ b/app/controllers/memberships/email_addresses/confirmations_controller.rb @@ -0,0 +1,28 @@ +class Memberships::EmailAddresses::ConfirmationsController < ApplicationController + require_untenanted_access + allow_unauthenticated_access + + before_action :set_membership + rate_limit to: 5, within: 1.hour, only: :create + + def show + end + + def create + membership = Membership.change_email_address_using_token(token) + + terminate_session + start_new_session_for membership.reload.identity + + redirect_to edit_user_url(script_name: "/#{@membership.tenant}", id: @membership.user) + end + + private + def set_membership + @membership = Membership.find(params[:membership_id]) + end + + def token + params.expect :email_address_token + end +end diff --git a/app/controllers/memberships/email_addresses_controller.rb b/app/controllers/memberships/email_addresses_controller.rb new file mode 100644 index 000000000..e918005e1 --- /dev/null +++ b/app/controllers/memberships/email_addresses_controller.rb @@ -0,0 +1,22 @@ +class Memberships::EmailAddressesController < ApplicationController + require_untenanted_access + + before_action :set_membership + rate_limit to: 5, within: 1.hour, only: :create + + def new + end + + def create + @membership.send_email_address_change_confirmation(new_email_address) + end + + private + def set_membership + @membership = Current.identity.memberships.find(params[:membership_id]) + end + + def new_email_address + params.expect :email_address + end +end diff --git a/app/controllers/memberships/unlink_controller.rb b/app/controllers/memberships/unlink_controller.rb new file mode 100644 index 000000000..63664aca8 --- /dev/null +++ b/app/controllers/memberships/unlink_controller.rb @@ -0,0 +1,17 @@ +class Memberships::UnlinkController < ApplicationController + require_untenanted_access + before_action :set_membership + + def show + end + + def create + @membership.destroy + redirect_to session_menu_path + end + + private + def set_membership + @membership = Current.identity.memberships.find_signed!(params[:membership_id], purpose: :unlinking) + end +end diff --git a/app/controllers/my/menus_controller.rb b/app/controllers/my/menus_controller.rb index 2fac3d8d0..900cf7c0a 100644 --- a/app/controllers/my/menus_controller.rb +++ b/app/controllers/my/menus_controller.rb @@ -2,6 +2,6 @@ class My::MenusController < ApplicationController include FilterScoped def show - fresh_when etag: [ @user_filtering, Current.identity_token ] + fresh_when etag: [ @user_filtering, Current.session ] end end diff --git a/app/controllers/notifications/unsubscribes_controller.rb b/app/controllers/notifications/unsubscribes_controller.rb index f7486d91e..ca15e78d4 100644 --- a/app/controllers/notifications/unsubscribes_controller.rb +++ b/app/controllers/notifications/unsubscribes_controller.rb @@ -1,5 +1,6 @@ class Notifications::UnsubscribesController < ApplicationController allow_unauthenticated_access + allow_unauthorized_access skip_before_action :verify_authenticity_token before_action :set_user diff --git a/app/controllers/public/cards_controller.rb b/app/controllers/public/cards_controller.rb index 9434882e7..50d9b1ed0 100644 --- a/app/controllers/public/cards_controller.rb +++ b/app/controllers/public/cards_controller.rb @@ -2,6 +2,7 @@ class Public::CardsController < ApplicationController include CachedPublicly, PublicCardScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/public/collections/columns/closeds_controller.rb b/app/controllers/public/collections/columns/closeds_controller.rb index f41e088f2..c049785cb 100644 --- a/app/controllers/public/collections/columns/closeds_controller.rb +++ b/app/controllers/public/collections/columns/closeds_controller.rb @@ -2,6 +2,7 @@ class Public::Collections::Columns::ClosedsController < ApplicationController include CachedPublicly, PublicCollectionScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/public/collections/columns/not_nows_controller.rb b/app/controllers/public/collections/columns/not_nows_controller.rb index 64382869d..d3cd2ed02 100644 --- a/app/controllers/public/collections/columns/not_nows_controller.rb +++ b/app/controllers/public/collections/columns/not_nows_controller.rb @@ -2,6 +2,7 @@ class Public::Collections::Columns::NotNowsController < ApplicationController include CachedPublicly, PublicCollectionScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/public/collections/columns/streams_controller.rb b/app/controllers/public/collections/columns/streams_controller.rb index a1fb07391..a8a2165e5 100644 --- a/app/controllers/public/collections/columns/streams_controller.rb +++ b/app/controllers/public/collections/columns/streams_controller.rb @@ -2,6 +2,7 @@ class Public::Collections::Columns::StreamsController < ApplicationController include CachedPublicly, PublicCollectionScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/public/collections/columns_controller.rb b/app/controllers/public/collections/columns_controller.rb index 9f32afc1b..dcc2b46a3 100644 --- a/app/controllers/public/collections/columns_controller.rb +++ b/app/controllers/public/collections/columns_controller.rb @@ -2,6 +2,7 @@ class Public::Collections::ColumnsController < ApplicationController include ActionView::RecordIdentifier, CachedPublicly, PublicCollectionScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/public/collections_controller.rb b/app/controllers/public/collections_controller.rb index 46fb5d750..18ae24022 100644 --- a/app/controllers/public/collections_controller.rb +++ b/app/controllers/public/collections_controller.rb @@ -2,6 +2,7 @@ class Public::CollectionsController < ApplicationController include CachedPublicly, PublicCollectionScoped allow_unauthenticated_access only: :show + allow_unauthorized_access only: :show layout "public" diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb new file mode 100644 index 000000000..3b836e445 --- /dev/null +++ b/app/controllers/sessions/magic_links_controller.rb @@ -0,0 +1,24 @@ +class Sessions::MagicLinksController < ApplicationController + require_untenanted_access + require_unauthenticated_access + rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Try again in 15 minutes." } + + layout "public" + + def show + end + + def create + if identity = MagicLink.consume(code) + start_new_session_for identity + redirect_to after_authentication_url + else + redirect_to session_magic_link_path, alert: "Try another code." + end + end + + private + def code + params.expect(:code) + end +end diff --git a/app/controllers/sessions/menus_controller.rb b/app/controllers/sessions/menus_controller.rb new file mode 100644 index 000000000..91f3d742b --- /dev/null +++ b/app/controllers/sessions/menus_controller.rb @@ -0,0 +1,17 @@ +class Sessions::MenusController < ApplicationController + require_untenanted_access + + layout "public" + + def show + if params[:menu_section] + request.variant = :menu_section + end + + @memberships = Current.identity.memberships + + if params[:without] + @memberships = @memberships.where.not(tenant: params[:without]) + end + end +end diff --git a/app/controllers/sessions/transfers_controller.rb b/app/controllers/sessions/transfers_controller.rb index d30c263b6..b3b7ae773 100644 --- a/app/controllers/sessions/transfers_controller.rb +++ b/app/controllers/sessions/transfers_controller.rb @@ -1,13 +1,14 @@ class Sessions::TransfersController < ApplicationController + require_untenanted_access require_unauthenticated_access def show end def update - if user = User.active.find_by_transfer_id(params[:id]) - start_new_session_for user - redirect_to root_path + if identity = Identity.find_by_transfer_id(params[:id]) + start_new_session_for identity + redirect_to session_menu_path(script_name: nil) else head :bad_request end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 16d368c59..25cb98408 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,21 +1,26 @@ class SessionsController < ApplicationController - require_unauthenticated_access only: %i[ new create ] + require_untenanted_access + require_unauthenticated_access except: :destroy rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + layout "public" + def new end def create - if user = User.active.authenticate_by(params.permit(:email_address, :password)) - start_new_session_for user - redirect_to after_authentication_url - else - redirect_to new_session_path, alert: "Try another email address or password." - end + Identity.find_by_email_address(email_address)&.send_magic_link + + redirect_to session_magic_link_path end def destroy terminate_session redirect_to_logout_url end + + private + def email_address + params.expect(:email_address) + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 11b793b17..811261c87 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,21 +1,23 @@ class UsersController < ApplicationController - require_unauthenticated_access only: %i[ new create ] - include FilterScoped - before_action :set_user, only: %i[ show edit update destroy ] + require_access_without_a_user only: %i[ new create ] + + before_action :set_join_code, only: %i[ new create] before_action :ensure_join_code_is_valid, only: %i[ new create ] - before_action :ensure_permission_to_change_user, only: %i[ update destroy ] + before_action :set_user, only: %i[ show edit update destroy ] + before_action :ensure_permission_to_change_user, only: %i[ update destroy ] before_action :set_filter, only: %i[ edit show ] before_action :set_user_filtering, only: %i[ edit show] def new - @user = User.new end def create - user = User.create!(user_params) - start_new_session_for user + @join_code.redeem do + User.create!(user_params.merge(membership: Current.membership)) + end + redirect_to root_path end @@ -38,8 +40,14 @@ class UsersController < ApplicationController end private + def set_join_code + @join_code = Account::JoinCode.active.find_by(code: Current.membership.join_code) + end + def ensure_join_code_is_valid - head :forbidden unless Account.sole.join_code == params[:join_code] + unless @join_code&.active? + redirect_to unlink_membership_url(script_name: nil, membership_id: Current.membership.signed_id(purpose: :unlinking)) + end end def set_user @@ -59,6 +67,6 @@ class UsersController < ApplicationController end def user_params - params.expect(user: [ :name, :email_address, :password, :avatar ]) + params.expect(user: [ :name, :avatar ]) end end diff --git a/app/helpers/login_helper.rb b/app/helpers/login_helper.rb index 0eed1781b..56627d968 100644 --- a/app/helpers/login_helper.rb +++ b/app/helpers/login_helper.rb @@ -1,6 +1,6 @@ module LoginHelper def login_url - new_session_path + new_session_path(script_name: nil) end def logout_url diff --git a/app/javascript/controllers/auto_submit_controller.js b/app/javascript/controllers/auto_submit_controller.js index 70cf1ee6a..6f34db998 100644 --- a/app/javascript/controllers/auto_submit_controller.js +++ b/app/javascript/controllers/auto_submit_controller.js @@ -2,7 +2,42 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { - this.element.addEventListener("turbo:submit-end", () => this.element.remove(), { once: true } ) + this.element.addEventListener("turbo:submit-end", this.#handleSubmitEnd.bind(this), { once: true }) + this.submit() + } + + submit() { + this.#markAsBusy() + this.#disableSubmit() this.element.requestSubmit() } + + #handleSubmitEnd(event) { + if (event.detail.success) { + this.element.remove() + } else { + this.#clearBusy() + this.#enableSubmit() + } + } + + #markAsBusy() { + this.element.setAttribute("aria-busy", "true") + } + + #clearBusy() { + this.element.setAttribute("aria-busy", "false") + } + + #disableSubmit() { + this.#submitElements().forEach(element => element.disabled = true) + } + + #enableSubmit() { + this.#submitElements().forEach(element => element.disabled = false) + } + + #submitElements() { + return this.element.querySelectorAll("input[type=submit],button") + } } diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js index db1602feb..68d6b554b 100644 --- a/app/javascript/controllers/form_controller.js +++ b/app/javascript/controllers/form_controller.js @@ -20,7 +20,7 @@ export default class extends Controller { const input = this.hasInputTarget ? this.inputTarget : null if (input) { - const value = (input.value || "").trim() + const value = (input.value || "").trim() if (value.length === 0) { event.preventDefault() } diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 27734a43d..d6db07d61 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -7,6 +7,10 @@ class ApplicationMailer < ActionMailer::Base private def default_url_options - super.merge(script_name: Account.sole.slug) + if ApplicationRecord.current_tenant + super.merge(script_name: Account.sole.slug) + else + super + end end end diff --git a/app/mailers/identity_mailer.rb b/app/mailers/identity_mailer.rb new file mode 100644 index 000000000..58ac5d0b6 --- /dev/null +++ b/app/mailers/identity_mailer.rb @@ -0,0 +1,7 @@ +class IdentityMailer < ApplicationMailer + def email_change_confirmation(email_address:, token:, membership:) + @token = token + @membership = membership + mail to: email_address, subject: "Confirm your new email address" + end +end diff --git a/app/mailers/magic_link_mailer.rb b/app/mailers/magic_link_mailer.rb new file mode 100644 index 000000000..391e64f6d --- /dev/null +++ b/app/mailers/magic_link_mailer.rb @@ -0,0 +1,13 @@ +class MagicLinkMailer < ApplicationMailer + def sign_in_instructions(magic_link) + @magic_link = magic_link + @identity = @magic_link.identity + + mail to: @identity.email_address, subject: "Sign in to Fizzy" + end + + private + def default_url_options + Rails.application.config.action_mailer.default_url_options + end +end diff --git a/app/mailers/notification/bundle_mailer.rb b/app/mailers/notification/bundle_mailer.rb index 4c7de3e68..32f883efd 100644 --- a/app/mailers/notification/bundle_mailer.rb +++ b/app/mailers/notification/bundle_mailer.rb @@ -10,7 +10,7 @@ class Notification::BundleMailer < ApplicationMailer @unsubscribe_token = @user.generate_token_for(:unsubscribe) mail \ - to: bundle.user.email_address, + to: bundle.user.identity.email_address, subject: "Latest Activity in BOXCAR" end end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 000000000..09926c09b --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,8 @@ +class UserMailer < ApplicationMailer + def email_change_confirmation(user:, email_address:, token:) + @user = user + @token = token + + mail to: email_address, subject: "Confirm your new email address" + end +end diff --git a/app/models/account.rb b/app/models/account.rb index da2298c88..357d0cc29 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,13 +1,18 @@ class Account < ApplicationRecord - include Entropic, Joinable + include Entropic has_many_attached :uploads + after_create :create_join_code + + validates :name, presence: true + class << self def create_with_admin_user(account:, owner:) - User.system - User.create!(**owner.reverse_merge(role: "admin", password: SecureRandom.hex(16))) - create!(**account) + create!(**account).tap do + User.system + User.create!(**owner.reverse_merge(role: "admin")) + end end end @@ -25,4 +30,9 @@ class Account < ApplicationRecord Collection.create!(name: "Cards", creator: user, all_access: true) end + + private + def create_join_code + Account::JoinCode.create! + end end diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb new file mode 100644 index 000000000..ddf4cf822 --- /dev/null +++ b/app/models/account/join_code.rb @@ -0,0 +1,36 @@ +class Account::JoinCode < ApplicationRecord + CODE_LENGTH = 12 + + scope :active, -> { where("usage_count < usage_limit") } + + before_validation :generate_code, on: :create, if: -> { code.blank? } + + validates :code, presence: true, uniqueness: true + validates :usage_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :usage_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + def redeem + transaction do + increment!(:usage_count) + yield if block_given? + end + end + + def active? + usage_count < usage_limit + end + + def reset + generate_code + self.usage_count = 0 + save! + end + + private + def generate_code + self.code = loop do + candidate = SecureRandom.base58(CODE_LENGTH).scan(/.{4}/).join("-") + break candidate unless self.class.exists?(code: candidate) + end + end +end diff --git a/app/models/account/joinable.rb b/app/models/account/joinable.rb deleted file mode 100644 index 3f313ce3a..000000000 --- a/app/models/account/joinable.rb +++ /dev/null @@ -1,16 +0,0 @@ -module Account::Joinable - extend ActiveSupport::Concern - - included do - before_create { self.join_code = generate_join_code } - end - - def reset_join_code - update! join_code: generate_join_code - end - - private - def generate_join_code - SecureRandom.alphanumeric(12).scan(/.{4}/).join("-") - end -end diff --git a/app/models/current.rb b/app/models/current.rb index 9974e5db9..a3ba812a6 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,6 +1,15 @@ class Current < ActiveSupport::CurrentAttributes - attribute :session, :identity_token + attribute :session, :membership attribute :http_method, :request_id, :user_agent, :ip_address, :referrer - delegate :user, to: :session, allow_nil: true + delegate :identity, to: :session, allow_nil: true + delegate :user, to: :membership, allow_nil: true + + def session=(value) + super(value) + + unless value.nil? + self.membership = identity.memberships.find_by(tenant: ApplicationRecord.current_tenant) + end + end end diff --git a/app/models/identity.rb b/app/models/identity.rb index bbf798dd1..70bc089eb 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,7 +1,20 @@ class Identity < UntenantedRecord - # This is used to instantiate an Identity-like object from the `identity_token` without hitting - # the untenanted database. It is intended to be used with caching/etagging methods. - Mock = Struct.new(:id, :updated_at) + include Transferable has_many :memberships, dependent: :destroy + has_many :magic_links, dependent: :destroy + has_many :sessions, dependent: :destroy + + normalizes :email_address, with: ->(value) { value.strip.downcase } + validates :email_address, presence: true + + def send_magic_link + magic_links.create!.tap do |magic_link| + MagicLinkMailer.sign_in_instructions(magic_link).deliver_later + end + end + + def staff? + email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com") + end end diff --git a/app/models/identity/transferable.rb b/app/models/identity/transferable.rb new file mode 100644 index 000000000..7806c51ce --- /dev/null +++ b/app/models/identity/transferable.rb @@ -0,0 +1,15 @@ +module Identity::Transferable + extend ActiveSupport::Concern + + TRANSFER_LINK_EXPIRY_DURATION = 4.hours + + class_methods do + def find_by_transfer_id(id) + find_signed(id, purpose: :transfer) + end + end + + def transfer_id + signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION) + end +end diff --git a/app/models/magic_link.rb b/app/models/magic_link.rb new file mode 100644 index 000000000..f596a2bd3 --- /dev/null +++ b/app/models/magic_link.rb @@ -0,0 +1,41 @@ +class MagicLink < UntenantedRecord + CODE_LENGTH = 6 + EXPIRATION_TIME = 15.minutes + + belongs_to :identity + + scope :active, -> { where(expires_at: Time.current...) } + scope :stale, -> { where(expires_at: ..Time.current) } + + before_validation :generate_code, on: :create + before_validation :set_expiration, on: :create + + validates :code, uniqueness: true, presence: true + + class << self + def consume(code) + active.find_by(code: Code.sanitize(code))&.consume + end + + def cleanup + stale.delete_all + end + end + + def consume + destroy + identity + end + + private + def generate_code + self.code ||= loop do + candidate = Code.generate(CODE_LENGTH) + break candidate unless self.class.exists?(code: candidate) + end + end + + def set_expiration + self.expires_at ||= EXPIRATION_TIME.from_now + end +end diff --git a/app/models/magic_link/code.rb b/app/models/magic_link/code.rb new file mode 100644 index 000000000..6af1619d8 --- /dev/null +++ b/app/models/magic_link/code.rb @@ -0,0 +1,31 @@ +module MagicLink::Code + CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".chars.freeze + CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze + + class << self + def generate(length) + length.times.map { CODE_ALPHABET.sample }.join + end + + def sanitize(code) + return nil if code.blank? + + normalize_code(code) + .then { apply_substitutions(_1) } + .then { remove_invalid_characters(_1) } + end + + private + def normalize_code(code) + code.to_s.upcase + end + + def apply_substitutions(code) + CODE_SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) } + end + + def remove_invalid_characters(code) + code.gsub(/[^#{CODE_ALPHABET.join}]/, "") + end + end +end diff --git a/app/models/membership.rb b/app/models/membership.rb index a8ac585ab..3a5997d5b 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -1,13 +1,29 @@ class Membership < UntenantedRecord + include EmailAddressChangeable + belongs_to :identity, touch: true - # I want this to be `belongs_to :user`, but ActiveRecord::Tenanted doesn't yet support - # associations from untenanted to untenanted models. - # - # See https://github.com/basecamp/activerecord-tenanted/issues/201 - # - # In the meantime, when creating a Membership, specify both `user_id` and `user_tenant` attributes. + class << self + def change_email_address(from:, to:, tenant:) + identity = Identity.find_by(email_address: from) + membership = find_by(tenant: tenant, identity: identity) + + if membership + new_identity = Identity.find_or_create_by!(email_address: to) + membership.update!(identity: new_identity) + end + end + end + + def account_name + ApplicationRecord.with_tenant(tenant) { Account.sole.name } + rescue ActiveRecord::Tenanted::TenantDoesNotExistError + nil + end + def user - User.with_tenant(user_tenant) { User.find_by(id: user_id) } + ApplicationRecord.with_tenant(tenant) { User.find_by(membership_id: id) } + rescue ActiveRecord::Tenanted::TenantDoesNotExistError + nil end end diff --git a/app/models/membership/email_address_changeable.rb b/app/models/membership/email_address_changeable.rb new file mode 100644 index 000000000..5ec2b554a --- /dev/null +++ b/app/models/membership/email_address_changeable.rb @@ -0,0 +1,57 @@ +module Membership::EmailAddressChangeable + EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address" + EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes + + extend ActiveSupport::Concern + + class_methods do + def change_email_address_using_token(token) + parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE) + membership = parsed_token&.find + + if parsed_token.nil? + raise ArgumentError, "The token is invalid" + elsif membership.nil? + raise ArgumentError, "The membership no longer exists" + elsif membership.identity.email_address != parsed_token.params.fetch("old_email_address") + raise ArgumentError, "The token was generated for a different email address" + else + new_email_address = parsed_token.params.fetch("new_email_address") + membership.change_email_address(new_email_address) + end + + membership + end + end + + def send_email_address_change_confirmation(new_email_address) + token = generate_email_address_change_token( + to: new_email_address, + expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION + ) + + IdentityMailer.email_change_confirmation( + email_address: new_email_address, + token: token, + membership: self + ).deliver_later + end + + def change_email_address(new_email_address) + transaction do + new_identity = Identity.find_or_create_by!(email_address: new_email_address) + update!(identity: new_identity) + end + end + + private + def generate_email_address_change_token(from: identity.email_address, to:, **options) + options = options.reverse_merge( + for: EMAIL_CHANGE_TOKEN_PURPOSE, + old_email_address: from, + new_email_address: to, + ) + + to_sgid(**options).to_s + end +end diff --git a/app/models/session.rb b/app/models/session.rb index cf376fb28..92d0f20b2 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,3 +1,3 @@ -class Session < ApplicationRecord - belongs_to :user +class Session < UntenantedRecord + belongs_to :identity end diff --git a/app/models/user.rb b/app/models/user.rb index 14b2cff93..2046b64d5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,13 +1,15 @@ class User < ApplicationRecord - include Accessor, Assignee, Attachable, Configurable, Identifiable, - Mentionable, Named, Notifiable, Role, Searcher, Staff, Transferable, - Watcher + include Accessor, Assignee, Attachable, Configurable, + Mentionable, Named, Notifiable, Role, Searcher, Watcher include Timelined # Depends on Accessor + self.ignored_columns = %i[ password_digest ] + has_one_attached :avatar - has_many :sessions, dependent: :destroy - has_secure_password validations: false + belongs_to :membership, optional: true + + has_one :identity, through: :membership, disable_joins: true has_many :comments, inverse_of: :creator, dependent: :destroy @@ -18,14 +20,10 @@ class User < ApplicationRecord normalizes :email_address, with: ->(value) { value.strip.downcase } - def deactivate - sessions.delete_all - accesses.destroy_all - update! active: false, email_address: deactived_email_address - end + delegate :staff?, to: :identity, allow_nil: true - private - def deactived_email_address - email_address.sub(/@/, "-deactivated-#{SecureRandom.uuid}@") - end + def deactivate + accesses.destroy_all + update! active: false + end end diff --git a/app/models/user/identifiable.rb b/app/models/user/identifiable.rb deleted file mode 100644 index 0b3f0abbc..000000000 --- a/app/models/user/identifiable.rb +++ /dev/null @@ -1,28 +0,0 @@ -module User::Identifiable - extend ActiveSupport::Concern - - included do - has_one :membership, ->(user) { where(user_tenant: user.tenant) } - has_one :identity, through: :membership - end - - def set_identity(token_identity) - if token_identity.present? - if identity.nil? - token_identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name) - elsif identity != token_identity - Identity.transaction do - identity.memberships.update_all(identity_id: token_identity.id) - identity.destroy - end - end - token_identity - elsif identity.present? - identity - else - Identity.create!.tap do |identity| - identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name) - end - end - end -end diff --git a/app/models/user/staff.rb b/app/models/user/staff.rb deleted file mode 100644 index c9b027088..000000000 --- a/app/models/user/staff.rb +++ /dev/null @@ -1,7 +0,0 @@ -module User::Staff - extend ActiveSupport::Concern - - def staff? - email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com") - end -end diff --git a/app/views/account/join_codes/edit.html.erb b/app/views/account/join_codes/edit.html.erb new file mode 100644 index 000000000..c96a4ac82 --- /dev/null +++ b/app/views/account/join_codes/edit.html.erb @@ -0,0 +1,36 @@ +<% @page_title = "Change usage limit" %> + +<% content_for :header do %> + <%= render "my/menus/menu" %> +
+ <%= link_to account_join_code_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + Invite link + <% end %> +
+<% end %> + +
+
+

<%= @page_title %>

+

How many times can this link be used to join the account?

+
+ <%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %> + <%= form.number_field :usage_limit, + required: true, + autofocus: true, + min: @join_code.usage_count, + class: "input center txt-large fit-content font-weight-black txt-align-center", + style: "max-inline-size: 8ch", + data: { action: "keydown.esc@document->form#cancel focus->form#select" } %> + +

+ This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times. +

+ + <%= form.button type: :submit, class: "btn btn--link center txt-medium", data: { form_target: "submit" } do %> + Save changes + <% end %> + + <%= link_to "Go back", account_join_code_path, data: { form_target: "cancel" }, hidden: true %> + <% end %> +
diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb new file mode 100644 index 000000000..f076518ff --- /dev/null +++ b/app/views/account/join_codes/show.html.erb @@ -0,0 +1,68 @@ +<% @page_title = "Add people" %> + +<% content_for :header do %> + <%= render "my/menus/menu" %> +
+ <%= link_to account_settings_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + Account Settings + <% end %> +
+<% end %> + +
+
+

<%= @page_title %>

+

Share the link below to invite people to this account

+
+ + <% url = join_url(code: @join_code.code, tenant: @join_code.tenant, script_name: nil) %> +
+ + <%= button_to account_join_code_path, method: :delete, class: "btn btn--circle txt-small", + data: { turbo_confirm: "Are you sure you want to generate a new link? The previous code will stop working." } do %> + <%= icon_tag "refresh" %> + Generate a new code + <% end %> +
+ +
+ <%= tag.button class: "btn btn--link", data: { + controller: "copy-to-clipboard", action: "copy-to-clipboard#copy", + copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: url } do %> + + <%= icon_tag "copy-paste" %> + Copy invite link + <% end %> + +
+ <%= tag.button class: "btn", data: { action: "dialog#open" } do %> + <%= icon_tag "qr-code" %> + Get QR code + <% end %> + + +

Scan this code with the camera on your mobile device

+ + <%= qr_code_image(url) %> + +
+ +
+
+
+
+ + +
diff --git a/app/views/account/settings/_invite.html.erb b/app/views/account/settings/_invite.html.erb deleted file mode 100644 index 4bc7562f0..000000000 --- a/app/views/account/settings/_invite.html.erb +++ /dev/null @@ -1,40 +0,0 @@ -
- <% url = join_url(Account.sole.join_code) %> - - - -
-
- <%= tag.button class: "btn", data: { action: "dialog#open" } do %> - <%= icon_tag "qr-code" %> - Show join link QR code - <% end %> - - - <%= image_tag account_join_code_path, class: "qr-code center", alt: "QR Code", loading: "lazy" %> - -
- -
-
-
- - <%= button_to_copy_to_clipboard(url) do %> - <%= icon_tag "copy-paste" %> - Copy join link - <% end %> - - <%= button_to account_join_code_path, method: :put, class: "btn btn--regenerate" do %> - <%= icon_tag "refresh" %> - Regenerate join link - <% end %> -
-
diff --git a/app/views/account/settings/_name.html.erb b/app/views/account/settings/_name.html.erb new file mode 100644 index 000000000..2af2b98dc --- /dev/null +++ b/app/views/account/settings/_name.html.erb @@ -0,0 +1,8 @@ +

Account

+<%= form_with model: account, url: account_settings_path, method: :put, scope: :account, data: { controller: "form" }, class: "flex gap-half" do |form| %> + <%= form.text_field :name, required: true, class: "input input--transparent full-width", placeholder: "Account name",data: { action: "input->form#disableSubmitWhenInvalid" } %> + <%= form.button class: "btn btn--circle btn--link", data: { form_target: "submit" }, disabled: form.object do %> + <%= icon_tag "arrow-right" %> + Save changes + <% end %> +<% end %> diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index e47304df4..ebe8fefec 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -1,11 +1,15 @@ -
+<%= tag.div class: "margin-block-start flex flex-column gap-half", data: { + controller: "filter navigable-list", + action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset", + navigable_list_focus_on_selection_value: true, + navigable_list_actionable_items_value: true +} do %>

People on the account

- <%= render "account/settings/invite" %> + <%= link_to account_join_code_path, class: "btn btn--link center txt-small" do %> + <%= icon_tag "add" %> + Add people + <% end %>
@@ -14,4 +18,4 @@ <%= render partial: "account/settings/user", collection: users %>
-
+<% end %> diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index eed15ec7e..245643c20 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -5,6 +5,12 @@ <% end %>
- <%= render "account/settings/users", users: @users %> +
+ <% if Current.user.can_administer? %> + <%= render "account/settings/name", account: @account %> + <% end%> + <%= render "account/settings/users", users: @users %> +
+ <%= render "account/settings/entropy_configuration", account: @account %>
diff --git a/app/views/join_codes/new.html.erb b/app/views/join_codes/new.html.erb new file mode 100644 index 000000000..5974c16ba --- /dev/null +++ b/app/views/join_codes/new.html.erb @@ -0,0 +1,27 @@ +<% @page_title = "Join #{@account_name}" %> + +
+
+

+ <%= @page_title %> +

+

Enter your email address to continue

+
+ + <%= form_with url: join_path(code: params[:code], tenant: params[:tenant]), class: "flex flex-column gap txt-medium", data: { controller: "form" } do |form| %> +
+ +
+ + + <% end %> +
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb index ede44cc77..5e3c6e69f 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer.html.erb @@ -65,7 +65,7 @@ border-radius: 3rem; color: #ffffff; font-weight: 500; - padding: 0.2em 0.4em; + padding: 0.5em 1em; text-decoration: none; white-space: nowrap; } @@ -81,7 +81,7 @@ margin-bottom: 0; margin-top: 0; } - + .footer { border-top: 1px solid #ccc; margin-top: 3em; @@ -107,10 +107,18 @@ text-transform: uppercase; } + .margin-block-end-double { + margin-bottom: 2em; + } + + .margin-block-start-double { + margin-top: 2em; + } + .subtitle { font-size: 1.2em; font-weight: normal; - margin-bottom: 2em; + margin-bottom: 1em; margin-top: 0.1em; } @@ -119,6 +127,10 @@ font-weight: 900; margin-bottom: 0; } + + .txt-large { + font-size: 1.2em; + } diff --git a/app/views/layouts/public.html.erb b/app/views/layouts/public.html.erb index f8c93b208..6df1eabeb 100644 --- a/app/views/layouts/public.html.erb +++ b/app/views/layouts/public.html.erb @@ -5,7 +5,12 @@
@@ -13,10 +18,6 @@
+ <%= yield :footer %> diff --git a/app/views/mailers/identity_mailer/email_change_confirmation.html.erb b/app/views/mailers/identity_mailer/email_change_confirmation.html.erb new file mode 100644 index 000000000..438dc18e9 --- /dev/null +++ b/app/views/mailers/identity_mailer/email_change_confirmation.html.erb @@ -0,0 +1,9 @@ +

Confirm your email address change

+

Hit the button below to use this email address in Fizzy.

+ +<%= link_to "Yes use use this email address", email_address_confirmation_url(membership_id: @membership.id, email_address_token: @token), class: "btn" %> + +

If you didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.

+ + diff --git a/app/views/mailers/identity_mailer/email_change_confirmation.text.erb b/app/views/mailers/identity_mailer/email_change_confirmation.text.erb new file mode 100644 index 000000000..265c9cbf2 --- /dev/null +++ b/app/views/mailers/identity_mailer/email_change_confirmation.text.erb @@ -0,0 +1,8 @@ +Confirm your email address change +<%= "=" * 80 %> + +Hit the link below to use this email address in Fizzy: + +<%= email_address_confirmation_url(membership_id: @membership.id, email_address_token: @token) %> + +If you didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button. \ No newline at end of file diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb new file mode 100644 index 000000000..b8ae68967 --- /dev/null +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb @@ -0,0 +1,12 @@ +

Sign in to Fizzy

+

Hit the button below to sign in to Fizzy on this device

+ +<%= link_to "Sign in to Fizzy", session_magic_link_url(code: @magic_link.code), class: "btn" %> + +

If you're signing in on another device, enter this special code: +
<%= @magic_link.code %> +

+ + + diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb new file mode 100644 index 000000000..f44e1c908 --- /dev/null +++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb @@ -0,0 +1,8 @@ +Sign in to Fizzy +<%= "=" * 80 %> + +Open this link in your browser to login on this device: +<%= session_magic_link_url(code: @magic_link.code) %> + +If you're signing in on another device, enter this code: +<%= @magic_link.code %> diff --git a/app/views/mailers/notification/bundle_mailer/notification.html.erb b/app/views/mailers/notification/bundle_mailer/notification.html.erb index 19191c645..5983a2bc1 100644 --- a/app/views/mailers/notification/bundle_mailer/notification.html.erb +++ b/app/views/mailers/notification/bundle_mailer/notification.html.erb @@ -1,7 +1,7 @@

Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>

-

You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %>.

+

You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %>.

<%= render partial: "notification/bundle_mailer/notification", collection: @notifications, as: :notification %>
+
+

+ <%= @page_title %> +

+

Just a sec while we confirm your new email address.

+
+ + <%= form_with url: email_address_confirmation_path(membership_id: @membership.id), method: :post, data: { controller: "form auto-submit" } do |form| %> + <%= form.hidden_field :email_address_token, value: params[:email_address_token] %> + + + <% end %> +
diff --git a/app/views/memberships/email_addresses/create.html.erb b/app/views/memberships/email_addresses/create.html.erb new file mode 100644 index 000000000..53851105d --- /dev/null +++ b/app/views/memberships/email_addresses/create.html.erb @@ -0,0 +1,17 @@ +<% @page_title = "Confirm your new email address" %> + +<% content_for :header do %> +
+ <%= link_to edit_user_path(@membership.user, script_name: "/#{@tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + My profile + <% end %> +
+<% end %> + +
+

Check your email

+

We just sent an email to <%= params[:email_address] %>

+

Hit the link in the email to confirm this is the email address you want to use with Fizzy going forward.

+ + <%= link_to "Done", edit_user_path(@membership.user, script_name: "/#{@tenant}"), class: "btn btn--link center" %> +
diff --git a/app/views/memberships/email_addresses/new.html.erb b/app/views/memberships/email_addresses/new.html.erb new file mode 100644 index 000000000..f88456679 --- /dev/null +++ b/app/views/memberships/email_addresses/new.html.erb @@ -0,0 +1,31 @@ +<% @page_title = "Change your email" %> + +<% content_for :header do %> +
+ <%= link_to edit_user_path(@membership.user, script_name: "/#{@membership.tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + My profile + <% end %> +
+<% end %> + +
+
+

+ <%= @page_title %> +

+

Enter your new email address. We'll send you a confirmation to verify it.

+
+ + <%= form_with url: email_addresses_path(membership_id: @membership.id), method: :post, class: "flex flex-column gap", data: { controller: "form", turbo: false } do |form| %> +
+ +
+ + + <% end %> +
diff --git a/app/views/memberships/unlink/show.html.erb b/app/views/memberships/unlink/show.html.erb new file mode 100644 index 000000000..275b3582e --- /dev/null +++ b/app/views/memberships/unlink/show.html.erb @@ -0,0 +1,24 @@ +<% @page_title = "Leaving #{@membership.account_name}" %> + +
+
+

+ <%= @page_title %> +

+

You no longer have access to this account.

+
+ + <%= form_with \ + url: unlink_membership_path(membership_id: params[:membership_id]), + class: "flex flex-column gap txt-medium", + data: { controller: "form auto-submit" } do |form| %> + + <% end %> +
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/my/menus/_accounts.html.erb b/app/views/my/menus/_accounts.html.erb index a7532cea8..e8c850907 100644 --- a/app/views/my/menus/_accounts.html.erb +++ b/app/views/my/menus/_accounts.html.erb @@ -1,5 +1,3 @@ -<%= collapsible_nav_section "Accounts" do %> - <% Current.user.identity.memberships.each do |membership| %> - <%= filter_place_menu_item root_url(script_name: "/#{membership.user_tenant}"), "#{membership.account_name}", "marker", new_window: true, current: membership.user_tenant == Current.user.tenant %> - <% end %> +<%= turbo_frame_tag dom_id(Current.identity, :account_menu), src: session_menu_url(script_name: nil, menu_section: true, without: ApplicationRecord.current_tenant) do %> + Loading... <% end %> diff --git a/app/views/my/menus/_users.html.erb b/app/views/my/menus/_users.html.erb index 1d3f634cf..8fea7f8a3 100644 --- a/app/views/my/menus/_users.html.erb +++ b/app/views/my/menus/_users.html.erb @@ -1,3 +1,9 @@ <%= collapsible_nav_section "People" do %> + <%= render partial: "my/menus/users/user", collection: user_filtering.users, as: :user %> <% end %> diff --git a/app/views/sessions/_footer.html.erb b/app/views/sessions/_footer.html.erb new file mode 100644 index 000000000..208f47a3f --- /dev/null +++ b/app/views/sessions/_footer.html.erb @@ -0,0 +1,3 @@ +
+ BOXCAR beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %> +
\ No newline at end of file diff --git a/app/views/sessions/login_menu.html.erb b/app/views/sessions/login_menu.html.erb deleted file mode 100644 index 56f1e1373..000000000 --- a/app/views/sessions/login_menu.html.erb +++ /dev/null @@ -1,20 +0,0 @@ -<% @page_title = "Choose Account" %> - -
-

BOXCAR

- - <% identity = Identity.find_signed(Current.identity_token.id) %> - <% memberships = identity&.memberships %> - <% if memberships.present? %> -

Your BOXCAR Accounts

- - <% else %> -

You don't have any existing BOXCAR accounts.

- <% end %> -
diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb new file mode 100644 index 000000000..57932a475 --- /dev/null +++ b/app/views/sessions/magic_links/show.html.erb @@ -0,0 +1,21 @@ +<% @page_title = "Check your email" %> + +
"> +
+

<%= @page_title %>

+

We just emailed you a link to sign in.

+
+ +

If your email is on a different device, enter the code included in the email below.

+ + <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: token_list("form", "auto-submit" => params[:code].present?)} } do |form| %> + <%= form.text_field :code, required: true, class: "input center txt-align-enter txt-normal", + autofocus: false, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true, + autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code], + data: { form_target: "input", action: "paste->form#submit, keydown.enter->form#submit" } %> + <% end %> +
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/sessions/menus/show.html+menu_section.erb b/app/views/sessions/menus/show.html+menu_section.erb new file mode 100644 index 000000000..6aab21fe8 --- /dev/null +++ b/app/views/sessions/menus/show.html+menu_section.erb @@ -0,0 +1,9 @@ +<%= turbo_frame_tag dom_id(Current.identity, :account_menu) do %> + <% cache [ Current.identity, @memberships ] do %> + <%= collapsible_nav_section "Accounts" do %> + <% @memberships.each do |membership| %> + <%= filter_place_menu_item root_url(script_name: "/#{membership.tenant}"), "#{membership.account_name}", "marker", new_window: true %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb new file mode 100644 index 000000000..a61c415ca --- /dev/null +++ b/app/views/sessions/menus/show.html.erb @@ -0,0 +1,39 @@ +<% @page_title = "Choose an account" %> + +<% cache [ Current.identity, @memberships ] do %> +
+ <% if @memberships.present? %> +

+ Your BOXCAR accounts +

+ + <% @memberships.each do |membership| %> + + <% end %> + + <% else %> +

Hmm...

+

You don’t have any BOXCAR accounts.

+ <% end %> + + <% if defined?(saas) %> +
+ <%= link_to saas.new_signup_membership_path, class: "btn btn--plain txt-link center txt-small" do %> + Sign up for a new BOXCAR account + <% end %> +
+ <% end %> +
+<% end %> + +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 85cbddb81..be35c17b1 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,26 +1,22 @@ <% @page_title = "Sign in" %> -
" style="--panel-size: 55ch;"> -

BOXCAR

+
+

<%= @page_title %>

- <%= form_with url: session_path, class: "flex flex-column gap txt-large" do |form| %> + <%= form_with url: session_path, class: "flex flex-column gap txt-medium" do |form| %>
-
- -
- - <% end %>
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/sessions/starts/new.html.erb b/app/views/sessions/starts/new.html.erb new file mode 100644 index 000000000..7e0efe5e4 --- /dev/null +++ b/app/views/sessions/starts/new.html.erb @@ -0,0 +1,19 @@ +<% @hide_footer_frames = true %> +<% @page_title = "Signing in..." %> + +
+
+

+ <%= @page_title %> +

+

Just a sec while we sign you in with <%= Account.sole.name %>.

+
+ + <%= form_with url: session_start_path, method: :post, data: { controller: "form auto-submit" } do |form| %> + <%= form.button "Sign in", type: "submit", class: "btn btn-primary", data: { form_target: "submit", turbo_submits_with: "Signing in..." } %> + <% end %> +
+ +<% content_for :footer do %> + <%= render "sessions/footer" %> +<% end %> diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb index fc0d34f06..b260a152e 100644 --- a/app/views/users/_transfer.html.erb +++ b/app/views/users/_transfer.html.erb @@ -1,5 +1,5 @@
- <% url = session_transfer_url(user.transfer_id) %> + <% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>