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" %>
+
+<% end %>
+
+
+
+ <%= 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" %>
+
+<% end %>
+
+
+
+
+ <% 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) %>
+
+
+
+
+
+
+
+
+
+ ">
+ This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times
+ (<%= @join_code.active? ? @join_code.usage_limit - @join_code.usage_count : "none" %> remaining)
+ <%= link_to edit_account_join_code_path, class: @join_code.active? ? "txt-link" : "txt-negative txt-underline" do %>
+ Change limit
+ <% end %>
+
+
+
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) %>
-
-
- Share to invite more people
-
-
-
-
-
-
-
- <%= 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}" %>
+
+
+
+
+ <%= form_with url: join_path(code: params[:code], tenant: params[:tenant]), class: "flex flex-column gap txt-medium", data: { controller: "form" } do |form| %>
+
+
+ <%= form.email_field :email_address, required: true, class: "input full-width", autofocus: true, autocomplete: "username", placeholder: "Email address" %>
+
+
+
+
+ Join
+ <%= icon_tag "arrow-right" %>
+
+ <% 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 %>