Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false

# Don't add an extra indent level to methods after a `private`/`protected` modifier.
# (Omakase defaults this to `indented_internal_methods`.)
Layout/IndentationConsistency:
EnforcedStyle: normal
9 changes: 5 additions & 4 deletions app/channels/application_cable/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ def connect
end

private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end

def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
end
6 changes: 5 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
class ApplicationController < ActionController::Base
include Authentication
include SetCurrentOrganization # resolves Current.organization first
include Authentication # then resumes the session / Current.user

# Runs after authentication so Current.user is known: keep non-members out of tenants.
before_action :require_organization_membership

# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
Expand Down
56 changes: 28 additions & 28 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,40 @@ def allow_unauthenticated_access(**options)
end

private
def authenticated?
resume_session
end
def authenticated?
resume_session
end

def require_authentication
resume_session || request_authentication
end
def require_authentication
resume_session || request_authentication
end

def resume_session
Current.session ||= find_session_by_cookie
end
def resume_session
Current.session ||= find_session_by_cookie
end

def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end

def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end

def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end

def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end

def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
end
51 changes: 51 additions & 0 deletions app/controllers/concerns/set_current_organization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
module SetCurrentOrganization
extend ActiveSupport::Concern

included do
before_action :set_current_organization
# Every page belongs to a tenant. Runs before authentication so an org-less
# request is sent home rather than bounced through the sign-in page first.
before_action :require_organization
end

class_methods do
# Opt out of the tenant requirement (HomeController#show renders the apex landing).
def allow_without_organization(**options)
skip_before_action :require_organization, **options
end
end

private

def set_current_organization
return if current_subdomain.nil? # apex / www → no tenant

Current.organization = Organization.find_by(subdomain: current_subdomain)
raise ActionController::RoutingError, "Organization not found" if Current.organization.nil?
end

# nil on the apex (no subdomain) or the reserved "www" host
def current_subdomain
subdomain = request.subdomain.presence
subdomain unless subdomain == "www"
end

# Authenticated user browsing an org they don't belong to → bounce to apex, no message.
# Runs even on allow_unauthenticated_access actions, so resume the session ourselves
# to learn who (if anyone) is signed in before checking membership.
def require_organization_membership
return if Current.organization.nil?

resume_session
return if Current.user.nil?

unless Current.user.member_of?(Current.organization)
redirect_to root_url(subdomain: false), allow_other_host: true
end
end

# No tenant resolved (apex / no subdomain) → send to the apex home landing.
def require_organization
redirect_to root_url(subdomain: false), allow_other_host: true if Current.organization.nil?
end
end
6 changes: 6 additions & 0 deletions app/controllers/home_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
class HomeController < ApplicationController
allow_unauthenticated_access only: :show
allow_without_organization only: :show

def show
return if Current.organization

# Same URL, but the apex (no tenant) lists the community foundations to visit.
@organizations = Organization.order(:name)
render :landing
end
end
13 changes: 7 additions & 6 deletions app/controllers/passwords_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def new

def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
PasswordsMailer.reset(user, Current.organization).deliver_later
end

redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
Expand All @@ -27,9 +27,10 @@ def update
end

private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end

def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
end
23 changes: 18 additions & 5 deletions app/controllers/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,29 @@ def create

@user = User.new(registration_params)

if @user.save
RegistrationMailer.confirmation(@user).deliver_later
# Emails are globally unique. An existing account (member of this org or not)
# is sent to sign in rather than shown a uniqueness error.
if User.exists?(email_address: @user.email_address)
redirect_to new_session_path, notice: "You already have an account. Please sign in."
return
end

if @user.valid?
ActiveRecord::Base.transaction do
@user.save!
Current.organization.organization_memberships.create!(user: @user)
end

RegistrationMailer.confirmation(@user, Current.organization).deliver_later
redirect_to new_session_path, notice: "Check your email to confirm your account before signing in."
else
render :new, status: :unprocessable_entity
end
end

private
def registration_params
params.require(:user).permit(:email_address, :password, :password_confirmation)
end

def registration_params
params.require(:user).permit(:email_address, :password, :password_confirmation)
end
end
7 changes: 5 additions & 2 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ def new

def create
if user = User.authenticate_by(params.permit(:email_address, :password))
if user.confirmed?
if !user.confirmed?
redirect_to new_session_path, alert: "Please confirm your email address before signing in."
elsif user.member_of?(Current.organization)
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Please confirm your email address before signing in."
# Don't reveal whether the account belongs to this organization.
redirect_to new_session_path, alert: "Try another email address or password."
end
else
redirect_to new_session_path, alert: "Try another email address or password."
Expand Down
3 changes: 2 additions & 1 deletion app/mailers/passwords_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class PasswordsMailer < ApplicationMailer
def reset(user)
def reset(user, organization)
@user = user
@organization = organization
mail subject: "Reset your password", to: user.email_address
end
end
3 changes: 2 additions & 1 deletion app/mailers/registration_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
class RegistrationMailer < ApplicationMailer
def confirmation(user)
def confirmation(user, organization)
@user = user
@organization = organization
mail subject: "Confirm your email address", to: user.email_address
end
end
1 change: 1 addition & 0 deletions app/models/current.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
attribute :organization
delegate :user, to: :session, allow_nil: true
end
17 changes: 17 additions & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class Organization < ApplicationRecord
has_many :organization_memberships, dependent: :destroy
has_many :users, through: :organization_memberships

normalizes :subdomain, with: ->(s) { s.strip.downcase }

validates :name, presence: true
validates :subdomain,
presence: true,
uniqueness: { case_sensitive: false },
format: { with: /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/, message: "only letters, numbers, and hyphens" },
length: { maximum: 63 },
exclusion: { in: %w[ www mail ftp admin api app root ], message: "is reserved" }
validates :website,
format: { with: URI::DEFAULT_PARSER.make_regexp(%w[ http https ]), message: "must be a valid URL" },
allow_blank: true
end
6 changes: 6 additions & 0 deletions app/models/organization_membership.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class OrganizationMembership < ApplicationRecord
belongs_to :user
belongs_to :organization

validates :user_id, uniqueness: { scope: :organization_id }
end
6 changes: 6 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :organization_memberships, dependent: :destroy
has_many :organizations, through: :organization_memberships

generates_token_for :email_confirmation, expires_in: 1.day do
confirmed_at
Expand All @@ -11,6 +13,10 @@ class User < ApplicationRecord
validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :password, length: { minimum: 8 }, allow_nil: true

def member_of?(organization)
organization && organizations.exists?(organization.id)
end

def confirmed?
confirmed_at.present?
end
Expand Down
16 changes: 16 additions & 0 deletions app/views/home/landing.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div class="mx-auto max-w-xl w-full px-4 py-16 text-center">
<h1 class="font-bold text-4xl tracking-tight text-gray-900">Community Foundations</h1>
<p class="mt-3 text-gray-500">Visit one of the community foundation pages to sign in or create an account.</p>

<% if @organizations.any? %>
<ul class="mt-8 flex flex-col items-center gap-3">
<% @organizations.each do |organization| %>
<li>
<%= link_to organization.name, root_url(subdomain: organization.subdomain), class: "text-blue-600 hover:text-blue-500 font-medium" %>
</li>
<% end %>
</ul>
<% else %>
<p class="mt-8 text-gray-500">No community foundations have been set up yet.</p>
<% end %>
</div>
4 changes: 2 additions & 2 deletions app/views/home/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
<h1 class="font-bold text-4xl tracking-tight text-gray-900">
Hello, <span class="text-blue-600"><%= Current.user.email_address %></span>
</h1>
<p class="mt-3 text-gray-500">You're signed in.</p>
<p class="mt-3 text-gray-500">You're signed in to <%= Current.organization.name %>.</p>

<div class="mt-8 flex items-center justify-center gap-4">
<%= button_to "Sign out", session_path, method: :delete, class: "rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
</div>
<% else %>
<h1 class="font-bold text-4xl tracking-tight text-gray-900">Welcome</h1>
<h1 class="font-bold text-4xl tracking-tight text-gray-900">Welcome to <%= Current.organization.name %></h1>
<p class="mt-3 text-gray-500">Sign in to your account or create a new one to get started.</p>

<div class="mt-8 flex items-center justify-center gap-4">
Expand Down
2 changes: 1 addition & 1 deletion app/views/passwords_mailer/reset.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<p>
You can reset your password on
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token, subdomain: @organization.subdomain) %>.

This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
</p>
2 changes: 1 addition & 1 deletion app/views/passwords_mailer/reset.text.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
You can reset your password on
<%= edit_password_url(@user.password_reset_token) %>
<%= edit_password_url(@user.password_reset_token, subdomain: @organization.subdomain) %>

This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
2 changes: 1 addition & 1 deletion app/views/registration_mailer/confirmation.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<p>
Welcome! Please confirm your email address by visiting
<%= link_to "this confirmation page", email_confirmation_url(token: @user.generate_token_for(:email_confirmation)) %>.
<%= link_to "this confirmation page", email_confirmation_url(token: @user.generate_token_for(:email_confirmation), subdomain: @organization.subdomain) %>.

This link will expire in 24 hours.
</p>
2 changes: 1 addition & 1 deletion app/views/registration_mailer/confirmation.text.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Welcome! Please confirm your email address by visiting
<%= email_confirmation_url(token: @user.generate_token_for(:email_confirmation)) %>
<%= email_confirmation_url(token: @user.generate_token_for(:email_confirmation), subdomain: @organization.subdomain) %>

This link will expire in 24 hours.
3 changes: 3 additions & 0 deletions bin/setup
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ FileUtils.chdir APP_ROOT do
system! "bin/rails db:prepare"
system! "bin/rails db:reset" if ARGV.include?("--reset")

puts "\n== Seeding database =="
system! "bin/rails db:seed"

puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"

Expand Down
4 changes: 4 additions & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
# Set localhost to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "localhost", port: ENV.fetch("PORT", 3000) }

# Treat the whole host as the domain so tenant subdomains like "arlington.localhost"
# resolve to request.subdomain == "arlington" (and a bare "localhost" is the apex).
config.action_dispatch.tld_length = 0

# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log

Expand Down
Loading
Loading