Merge branch 'main' into mobile-app/scoped-stylesheets

* main: (26 commits)
  Update fizzy-saas
  Update to fizzy-saas to remove jobs from beta
  Claude can fetch the .mcp.json file that shipyard places in your home
  Update fizzy-saas
  This was moved to the engine
  Introduce an "owner" role, and prevent it from being administered
  Make sure the sqlite db is prepared in SAAS mode
  Update fizzy-saas gem
  Move env-specific config bits to the saas gem
  Migrate sqlite schema
  Use main branch of fizzy-saas
  Change copy
  Make sign up Magic Links work across devices
  Remove unused view
  Update signup with the design from signin
  Revert "Update to latest version with the env config bits moved"
  Update to latest version with the env config bits moved
  Organize everything under Signups
  Create signups controller
  update pipelines
  ...
This commit is contained in:
Adrien Maston
2025-12-01 09:36:52 +01:00
56 changed files with 394 additions and 187 deletions
+2 -1
View File
@@ -2,10 +2,11 @@ name: CI (OSS)
on:
pull_request:
if: github.event.pull_request.head.repo.full_name != github.repository
types: [opened, synchronize]
jobs:
test:
if: github.event.pull_request.head.repo.full_name != github.repository
uses: ./.github/workflows/test.yml
with:
saas: false
-18
View File
@@ -1,18 +0,0 @@
{
"mcpServers": {
"chrome-devtools": {
"type": "stdio",
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"]
},
"sentry": {
"type": "stdio",
"command": "npx",
"args": ["-y", "mcp-remote@latest", "https://mcp.sentry.dev/mcp"]
},
"grafana": {
"type": "http",
"url": "https://grafana.37signals.com/mcp"
}
}
}
+2 -2
View File
@@ -68,7 +68,7 @@ Fizzy uses **URL path-based multi-tenancy**:
**Passwordless magic link authentication**:
- Global `Identity` (email-based) can have `Users` in multiple Accounts
- Users belong to an Account and have roles: admin, member, system
- Users belong to an Account and have roles: owner, admin, member, system
- Sessions managed via signed cookies
- Board-level access control via `Access` records
@@ -84,7 +84,7 @@ Fizzy uses **URL path-based multi-tenancy**:
**User** → Account membership
- Belongs to Account and Identity
- Has role (admin/member/system)
- Has role (owner/admin/member/system)
- Board access via explicit `Access` records
**Board** → Primary organizational unit
+7 -7
View File
@@ -1,6 +1,6 @@
GIT
remote: https://github.com/basecamp/fizzy-saas
revision: 7f392bbbf9f5170d334b6ee2f6d240569bd157ed
revision: 130c8b23f9861a89feb59ca68f192bd05d51310c
specs:
fizzy-saas (0.1.0)
prometheus-client-mmap
@@ -16,7 +16,7 @@ GIT
yabeda-http_requests
yabeda-prometheus-mmap
yabeda-puma-plugin
yabeda-rails
yabeda-rails (>= 0.10)
GIT
remote: https://github.com/basecamp/queenbee-plugin
@@ -52,7 +52,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 4f7ab01bb5d6be78c7447dbb230c55027d08ae34
revision: 690ec8898318b8f50714e86676353ebe1551261e
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -423,7 +423,7 @@ GEM
rake-compiler-dock (1.9.1)
rb_sys (0.9.117)
rake-compiler-dock (= 1.9.1)
rdoc (6.15.1)
rdoc (6.16.1)
erb
psych (>= 4.0.0)
tsort
@@ -479,10 +479,10 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.1.1)
sentry-rails (6.2.0)
railties (>= 5.2.0)
sentry-ruby (~> 6.1.1)
sentry-ruby (6.1.1)
sentry-ruby (~> 6.2.0)
sentry-ruby (6.2.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sniffer (0.5.0)
+1 -1
View File
@@ -55,7 +55,7 @@ Under the hood, this will create or remove `tmp/email-dev.txt`.
## Deployment
We recommend [Kamal](https://kamal-deploy.org/) for deploying Fizzy. This project comes with a vanilla Rails template, you can find our production setup in [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).
We recommend [Kamal](https://kamal-deploy.org/) for deploying Fizzy. This project comes with a vanilla Rails template. You can find our production setup in [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).
### Web Push Notifications
Binary file not shown.

Before

Width:  |  Height:  |  Size: 932 KiB

After

Width:  |  Height:  |  Size: 488 KiB

@@ -9,9 +9,9 @@ class Sessions::MagicLinksController < ApplicationController
end
def create
if identity = MagicLink.consume(code)
start_new_session_for identity
redirect_to after_authentication_url
if magic_link = MagicLink.consume(code)
start_new_session_for magic_link.identity
redirect_to after_sign_in_url(magic_link)
else
redirect_to session_magic_link_path, alert: "Try another code."
end
@@ -21,4 +21,12 @@ class Sessions::MagicLinksController < ApplicationController
def code
params.expect(:code)
end
def after_sign_in_url(magic_link)
if magic_link.for_sign_up?
new_signup_completion_path
else
after_authentication_url
end
end
end
+4 -24
View File
@@ -1,13 +1,4 @@
class SessionsController < ApplicationController
# FIXME: Remove this before launch!
unless Rails.env.local?
http_basic_authenticate_with \
name: Rails.application.credentials.account_signup_http_basic_auth.name,
password: Rails.application.credentials.account_signup_http_basic_auth.password,
realm: "Fizzy Signup",
only: :create, unless: -> { Identity.exists?(email_address: email_address) }
end
disallow_account_scope
require_unauthenticated_access except: :destroy
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
@@ -19,10 +10,11 @@ class SessionsController < ApplicationController
def create
if identity = Identity.find_by_email_address(email_address)
handle_existing_user(identity)
elsif
handle_new_signup
magic_link = identity.send_magic_link
flash[:magic_link_code] = magic_link&.code if Rails.env.development?
end
redirect_to session_magic_link_path
end
def destroy
@@ -34,16 +26,4 @@ class SessionsController < ApplicationController
def email_address
params.expect(:email_address)
end
def handle_existing_user(identity)
magic_link = identity.send_magic_link
flash[:magic_link_code] = magic_link&.code if Rails.env.development?
redirect_to session_magic_link_path
end
def handle_new_signup
Signup.new(email_address: email_address).create_identity
session[:return_to_after_authenticating] = new_signup_completion_path
redirect_to session_magic_link_path
end
end
@@ -1,4 +1,4 @@
class Signup::CompletionsController < ApplicationController
class Signups::CompletionsController < ApplicationController
layout "public"
disallow_account_scope
+34
View File
@@ -0,0 +1,34 @@
class SignupsController < ApplicationController
# FIXME: Remove this before launch!
unless Rails.env.local?
http_basic_authenticate_with \
name: Rails.application.credentials.account_signup_http_basic_auth.name,
password: Rails.application.credentials.account_signup_http_basic_auth.password,
realm: "Fizzy Signup"
end
disallow_account_scope
allow_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." }
before_action :redirect_authenticated_user
layout "public"
def new
@signup = Signup.new
end
def create
Signup.new(signup_params).create_identity
redirect_to session_magic_link_path
end
private
def redirect_authenticated_user
redirect_to new_signup_completion_path if authenticated?
end
def signup_params
params.expect signup: :email_address
end
end
+8
View File
@@ -0,0 +1,8 @@
module UsersHelper
def role_display_name(user)
case user.role
when "admin" then "Administrator"
else user.role.titleize
end
end
end
+2 -2
View File
@@ -17,10 +17,10 @@ class Account < ApplicationRecord
validates :name, presence: true
class << self
def create_with_admin_user(account:, owner:)
def create_with_owner(account:, owner:)
create!(**account).tap do |account|
account.users.create!(role: :system, name: "System")
account.users.create!(**owner.reverse_merge(role: "admin"))
account.users.create!(**owner.reverse_merge(role: "owner"))
end
end
end
+1 -1
View File
@@ -8,7 +8,7 @@ class Account::ExternalIdSequence < ApplicationRecord
end
def value
first&.value
first&.value || self.next
end
private
+1 -1
View File
@@ -2,6 +2,6 @@ module Account::Seedeable
extend ActiveSupport::Concern
def setup_customer_template
Account::Seeder.new(self, users.active.where(role: :admin).first).seed
Account::Seeder.new(self, users.admin.first).seed
end
end
+4 -2
View File
@@ -12,8 +12,10 @@ class Identity < ApplicationRecord
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
def send_magic_link
magic_links.create!.tap do |magic_link|
def send_magic_link(**attributes)
attributes[:purpose] = attributes.delete(:for) if attributes.key?(:for)
magic_links.create!(attributes).tap do |magic_link|
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
end
end
+3 -1
View File
@@ -4,6 +4,8 @@ class MagicLink < ApplicationRecord
belongs_to :identity
enum :purpose, %w[ sign_in sign_up ], prefix: :for, default: :sign_in
scope :active, -> { where(expires_at: Time.current...) }
scope :stale, -> { where(expires_at: ..Time.current) }
@@ -24,7 +26,7 @@ class MagicLink < ApplicationRecord
def consume
destroy
identity
self
end
private
+3 -3
View File
@@ -18,7 +18,7 @@ class Signup
def create_identity
@identity = Identity.find_or_create_by!(email_address: email_address)
@identity.send_magic_link
@identity.send_magic_link for: :sign_up
end
def complete
@@ -54,7 +54,7 @@ class Signup
end
def create_account
@account = Account.create_with_admin_user(
@account = Account.create_with_owner(
account: {
external_account_id: @tenant,
name: generate_account_name
@@ -64,7 +64,7 @@ class Signup
identity: identity
}
)
@user = @account.users.find_by!(role: :admin)
@user = @account.users.find_by!(role: :owner)
@account.setup_customer_template
end
+11 -5
View File
@@ -2,18 +2,24 @@ module User::Role
extend ActiveSupport::Concern
included do
enum :role, %i[ admin member system ].index_by(&:itself), scopes: false
enum :role, %i[ owner admin member system ].index_by(&:itself), scopes: false
scope :member, -> { where(role: :member) }
scope :active, -> { where(active: true, role: %i[ admin member ]) }
scope :owner, -> { where(active: true, role: :owner) }
scope :admin, -> { where(active: true, role: %i[ owner admin ]) }
scope :member, -> { where(active: true, role: :member) }
scope :active, -> { where(active: true, role: %i[ owner admin member ]) }
def admin?
super || owner?
end
end
def can_change?(other)
admin? || other == self
(admin? && !other.owner?) || other == self
end
def can_administer?(other)
admin? && other != self
admin? && !other.owner? && other != self
end
def can_administer_board?(board)
+7 -5
View File
@@ -10,11 +10,13 @@
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-ink-medium); --border-style: dashed" aria-hidden="true">
<%= form_with model: user, url: user_role_path(user), data: { controller: "form" }, method: :patch do | form | %>
<label class="btn btn--circle" for="<%= dom_id(user, :role) %>" arial-label="Role: <%= user.admin? ? "Administrator" : "Member" %>">
<%= icon_tag "crown" %>
<span class="for-screen-reader">Role: <%= user.admin? ? "Administrator" : "Member" %></span>
<%= form.check_box :role, { data: { action: "form#submit" }, disabled: !Current.user.can_administer?(user), hidden: true, id: dom_id(user, :role) }, "admin", "member" %>
</label>
<span title="<%= role_display_name(user) %>">
<label class="btn btn--circle" for="<%= dom_id(user, :role) %>" aria-label="Role: <%= role_display_name(user) %>">
<%= icon_tag "crown" %>
<span class="for-screen-reader">Role: <%= role_display_name(user) %></span>
<%= form.check_box :role, { data: { action: "form#submit" }, disabled: !Current.user.can_administer?(user), checked: user.admin?, hidden: true, id: dom_id(user, :role) }, "admin", "member" %>
</label>
</span>
<% end %>
<%# FIXME: Move this Current.user check to a stimulus controller that just checks for admin? or the like we so we can cache user list %>
+1 -1
View File
@@ -70,7 +70,7 @@
<ul class="margin-block-half">
<% @top_accounts.each do |account| %>
<% admin_user = account.users.find { |u| u.role == "admin" } %>
<% admin_user = account.users.owner.first %>
<li class="flex align-start gap-half margin-block-start-half">
<div
class="flex-item-grow min-width overflow-ellipsis"
@@ -1,5 +1,5 @@
<% if @identity.users.any? %>
<% if @magic_link.for_sign_in? %>
<h1 class="title">Fizzy verification code</h1>
<p class="subtitle">Please enter this 6-character verification code on the Fizzy sign-in page:</p>
<% else %>
@@ -1,4 +1,4 @@
<% if @identity.users.any? %>
<% if @magic_link.for_sign_in? %>
Please enter this 6-character verification code on the Fizzy sign-in page:
<% else %>
Please enter this 6-character verification code on the Fizzy sign-up page to create your new account:
@@ -1,9 +0,0 @@
<%= turbo_frame_tag Current.identity, :account_menu do %>
<% cache [ Current.identity, @accounts ] do %>
<%= collapsible_nav_section "Accounts" do %>
<% @accounts.each do |account| %>
<%= filter_place_menu_item landing_url(script_name: account.slug, account.name, "marker", new_window: true %>
<% end %>
<% end %>
<% end %>
<% end %>
+1 -1
View File
@@ -25,7 +25,7 @@
<% end %>
<div class="margin-block-start">
<%= link_to new_signup_completion_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %>
<%= link_to new_signup_path, class: "btn btn--plain txt-link center txt-small", data: { turbo_prefetch: false } do %>
<span>Sign up for a new Fizzy account</span>
<% end %>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@
</label>
</div>
<p><strong>New here?</strong> Enter your email to create an account. <strong>Already have an account?</strong> Enter your email and well get you signed in.</p>
<p><strong>New here?</strong> <%= link_to "sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and well get you signed in.</p>
<button type="submit" id="log_in" class="btn btn--link center txt-medium">
<span>Lets go</span>
-28
View File
@@ -1,28 +0,0 @@
<% @page_title = "Sign up for Fizzy" %>
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
<h1 class="txt-xx-large margin-block-end-double">Sign up</h1>
<%= form_with model: @signup, url: signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %>
<%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Email address", required: true %>
<% if @signup.errors.any? %>
<div class="margin-block-half">
<ul class="margin-block-none txt-negative txt-small">
<% @signup.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<button type="submit" class="btn btn--link center" data-form-target="submit">
<span>Continue</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+24
View File
@@ -0,0 +1,24 @@
<% @page_title = "Signup for Fizzy" %>
<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-x-large font-weight-black margin-block-end">Sign up</h1>
<%= form_with model: @signup, url: signup_path, scope: "signup", class: "flex flex-column gap", data: { turbo: false, controller: "form" } do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username", placeholder: "Enter your email address…" %>
</label>
</div>
<p>Enter your email to create an account.</p>
<button type="submit" id="log_in" class="btn btn--link center txt-medium">
<span>Lets go</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+5 -1
View File
@@ -8,4 +8,8 @@ require_relative "../config/boot"
require "rails/commands"
Fizzy::Saas.append_test_paths if Fizzy.saas? && Rails.env.test?
if Fizzy.saas? && ENV["RAILS_ENV"] == "test"
# the app is not loaded by rails/commands if there are additional arguments to "rails test"
require APP_PATH
Fizzy::Saas.append_test_paths
end
+7
View File
@@ -65,6 +65,13 @@ setup_database() {
local label="${adapter:+ ($adapter)}"
local env_cmd="${adapter:+env DATABASE_ADAPTER=$adapter}"
# When setting up sqlite in SAAS mode, we need to disable SAAS so that
# database.yml will use database.sqlite.yml instead of the fizzy-saas config.
# We also unset BUNDLE_GEMFILE so the standard Gemfile is used (without fizzy-saas).
if [ -n "$SAAS" ] && [ "$adapter" = "sqlite" ]; then
env_cmd="env DATABASE_ADAPTER=$adapter SAAS=false BUNDLE_GEMFILE="
fi
if [ "$reset" = "true" ]; then
step "Resetting the database$label" $env_cmd rails db:reset
else
+9 -4
View File
@@ -4,6 +4,7 @@ require_relative "../lib/fizzy"
OSS_ENV = "SAAS=false BUNDLE_GEMFILE=Gemfile"
SAAS_ENV = "SAAS=true BUNDLE_GEMFILE=Gemfile.saas"
SYSTEM_TEST_ENV = "PARALLEL_WORKERS=1" # system tests can't run reliably in parallel
CI.run do
step "Setup", "bin/setup --skip-server"
@@ -16,11 +17,15 @@ CI.run do
step "Security: Gitleaks audit", "bin/gitleaks-audit"
if Fizzy.saas?
step "Tests: SaaS", "#{SAAS_ENV} bin/rails test:all"
step "Tests: SQLite", "#{OSS_ENV} DATABASE_ADAPTER=sqlite bin/rails test:all"
step "Tests: SaaS", "#{SAAS_ENV} bin/rails test"
step "Tests: SaaS System", "#{SAAS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system"
step "Tests: SQLite", "#{OSS_ENV} DATABASE_ADAPTER=sqlite bin/rails test"
step "Tests: SQLite System", "#{OSS_ENV} DATABASE_ADAPTER=sqlite #{SYSTEM_TEST_ENV} bin/rails test:system"
else
step "Tests: MySQL", "#{OSS_ENV} DATABASE_ADAPTER=mysql bin/rails test:all"
step "Tests: SQLite", "#{OSS_ENV} DATABASE_ADAPTER=sqlite bin/rails test:all"
step "Tests: MySQL", "#{OSS_ENV} DATABASE_ADAPTER=mysql bin/rails test"
step "Tests: MySQL System", "#{OSS_ENV} DATABASE_ADAPTER=mysql #{SYSTEM_TEST_ENV} bin/rails test:system"
step "Tests: SQLite", "#{OSS_ENV} DATABASE_ADAPTER=sqlite bin/rails test"
step "Tests: SQLite System", "#{OSS_ENV} DATABASE_ADAPTER=sqlite #{SYSTEM_TEST_ENV} bin/rails test:system"
end
if success?
+1 -3
View File
@@ -1,11 +1,9 @@
<%
require_relative "../lib/fizzy"
config_path = if Fizzy.saas?
gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir
File.join(gem_path, "config", "database.yml")
else
File.join(__dir__, "database.#{Fizzy.db_adapter}.yml")
File.join("config", "database.#{Fizzy.db_adapter}.yml")
end
%>
<%= ERB.new(File.read(config_path)).result %>
-7
View File
@@ -1,8 +1 @@
require_relative "production"
Rails.application.configure do
config.action_mailer.smtp_settings[:domain] = "fizzy-beta.37signals.com"
config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging"
config.action_mailer.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" }
config.action_controller.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" }
end
-5
View File
@@ -88,9 +88,4 @@ Rails.application.configure do
# Set host to be used by links generated in mailer and notification view templates.
config.action_controller.default_url_options = { host: config.hosts.first, port: 3006 }
config.action_mailer.default_url_options = { host: config.hosts.first, port: 3006 }
if Rails.root.join("tmp/solid-queue.txt").exist?
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue, reading: :queue } }
end
end
-6
View File
@@ -69,12 +69,6 @@ Rails.application.configure do
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false
# Set host to be used by links generated in mailer and notification view templates.
config.action_controller.default_url_options = { host: "app.fizzy.do", protocol: "https" }
config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" }
config.action_mailer.smtp_settings = { domain: "app.fizzy.do", address: "smtp-outbound", port: 25, enable_starttls_auto: false }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
-7
View File
@@ -1,8 +1 @@
require_relative "production"
Rails.application.configure do
config.action_mailer.smtp_settings[:domain] = "fizzy.37signals-staging.com"
config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging"
config.action_mailer.default_url_options = { host: "fizzy.37signals-staging.com", protocol: "https" }
config.action_controller.default_url_options = { host: "fizzy.37signals-staging.com", protocol: "https" }
end
+7 -3
View File
@@ -155,10 +155,14 @@ Rails.application.routes.draw do
end
end
get "/signup/new", to: redirect("/session/new")
get "/signup", to: redirect("/signup/new")
namespace :signup do
resource :completion, only: %i[ new create ]
resource :signup, only: %i[ new create ] do
collection do
scope module: :signups, as: :signup do
resource :completion, only: %i[ new create ]
end
end
end
resource :landing
-2
View File
@@ -1,6 +1,4 @@
<%
require_relative "../lib/fizzy"
config_path = if Fizzy.saas?
gem_path = Gem::Specification.find_by_name("fizzy-saas").gem_dir
File.join(gem_path, "config", "storage.yml")
@@ -0,0 +1,11 @@
class AddPurposeToMagicLinks < ActiveRecord::Migration[8.2]
def change
add_column :magic_links, :purpose, :integer, null: true
execute <<-SQL
UPDATE magic_links SET purpose = 0
SQL
change_column_null :magic_links, :purpose, false
end
end
@@ -0,0 +1,14 @@
class PromoteFirstAdminToOwner < ActiveRecord::Migration[8.2]
def up
Account.find_each do |account|
next if account.users.exists?(role: :owner)
first_admin = account.users.where(role: :admin).order(:created_at).first
first_admin&.update!(role: :owner)
end
end
def down
User.where(role: :owner).update_all(role: :admin)
end
end
Generated
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_11_27_000001) do
ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -315,6 +315,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_27_000001) do
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.uuid "identity_id"
t.integer "purpose", null: false
t.datetime "updated_at", null: false
t.index ["code"], name: "index_magic_links_on_code", unique: true
t.index ["expires_at"], name: "index_magic_links_on_expires_at"
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_11_27_000001) do
ActiveRecord::Schema[8.2].define(version: 2025_11_29_175717) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -315,6 +315,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_27_000001) do
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.uuid "identity_id"
t.integer "purpose", null: false
t.datetime "updated_at", null: false
t.index ["code"], name: "index_magic_links_on_code", unique: true
t.index ["expires_at"], name: "index_magic_links_on_expires_at"
+1 -1
View File
@@ -17,7 +17,7 @@ else
identity = Identity.find_or_create_by!(email_address: email_address)
unless account = Account.find_by(external_account_id: tenant_id)
account = Account.create_with_admin_user(
account = Account.create_with_owner(
account: {
external_account_id: tenant_id,
name: signal_account_name
+3 -3
View File
@@ -122,7 +122,7 @@ class Import
def setup_account
step("Setting up account", "Account set up in %{duration}") do
oldest_admin = import.users.order(id: :asc).where(role: :admin, active: true).first
oldest_admin = import.users.order(id: :asc).admin.first
raise "No admin user found in the database" unless oldest_admin
membership = untenanted.memberships.find(oldest_admin.membership_id)
@@ -133,7 +133,7 @@ class Import
if Account.all.exists?(external_account_id: account.external_account_id)
raise AccountExistsError, "Account already exists"
else
@account = Account.create_with_admin_user(
@account = Account.create_with_owner(
account: {
external_account_id: account.external_account_id,
name: account.name.truncate(255, omission: "")
@@ -144,7 +144,7 @@ class Import
}
)
@tenant = @account.external_account_id
@admin = @account.users.find_by(role: :admin)
@admin = @account.users.find_by(role: :owner)
end
old_join_code = import.account_join_codes.sole
+2 -2
View File
@@ -37,11 +37,11 @@ tenant = ActiveRecord::FixtureSet.identify(identifier)
ApplicationRecord.with_tenant(tenant) do |tenant|
Current.account.destroy!
Account.create_with_admin_user \
Account.create_with_owner \
account: { name: "Company #{identifier}" },
owner: { name: "Developer #{identifier}", email_address: "dev-#{identifier}@example.com" }
user = User.find_by(role: :admin)
user = User.find_by(role: :owner)
identity = Identity.find_or_create_by(email_address: user.email_address)
identity.link_to(user.tenant)
Board.find_each do |board|
@@ -9,17 +9,32 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
end
end
test "create" do
test "create with sign in code" do
identity = identities(:kevin)
magic_link = MagicLink.create!(identity: identity)
untenanted do
post session_magic_link_url, params: { code: magic_link.code }
end
assert_response :redirect
assert cookies[:session_token].present?
assert_not MagicLink.exists?(magic_link.id), "The magic link should be consumed"
assert_response :redirect
assert cookies[:session_token].present?
assert_redirected_to landing_path, "Should redirect to after authentication path"
assert_not MagicLink.exists?(magic_link.id), "The magic link should be consumed"
end
end
test "create with sign up code" do
identity = identities(:kevin)
magic_link = MagicLink.create!(identity: identity, purpose: :sign_up)
untenanted do
post session_magic_link_url, params: { code: magic_link.code }
assert_response :redirect
assert cookies[:session_token].present?
assert_redirected_to new_signup_completion_path, "Should redirect to signup completion"
assert_not MagicLink.exists?(magic_link.id), "The magic link should be consumed"
end
end
test "create with invalid code" do
+3 -5
View File
@@ -23,11 +23,9 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
test "create for a new user" do
untenanted do
assert_difference -> { Identity.count }, +1 do
assert_difference -> { MagicLink.count }, +1 do
post session_path,
params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }
end
assert_no_difference -> { MagicLink.count } do
post session_path,
params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }
end
assert_redirected_to session_magic_link_path
@@ -0,0 +1,52 @@
require "test_helper"
class SignupsControllerTest < ActionDispatch::IntegrationTest
test "new" do
untenanted do
get new_signup_path
assert_response :success
end
end
test "new for an authenticated user" do
identity = identities(:kevin)
sign_in_as identity
untenanted do
get new_signup_path
assert_redirected_to new_signup_completion_path
end
end
test "create" do
email_address = "newuser-#{SecureRandom.hex(6)}@example.com"
untenanted do
assert_difference -> { Identity.count }, +1 do
assert_difference -> { MagicLink.count }, +1 do
post signup_path, params: { signup: { email_address: email_address } }
end
end
assert_redirected_to session_magic_link_path
end
end
test "create for an authenticated user" do
identity = identities(:kevin)
sign_in_as identity
untenanted do
assert_no_difference -> { Identity.count } do
assert_no_difference -> { MagicLink.count } do
post signup_path,
params: { signup: { email_address: identity.email_address } }
end
end
assert_redirected_to new_signup_completion_path
end
end
end
@@ -18,5 +18,29 @@ class Users::RolesControllerTest < ActionDispatch::IntegrationTest
assert_no_changes -> { users(:david).reload.role } do
put user_role_path(users(:david)), params: { user: { role: "system" } }
end
assert_no_changes -> { users(:david).reload.role } do
put user_role_path(users(:david)), params: { user: { role: "owner" } }
end
end
test "admin cannot demote the owner" do
assert users(:jason).owner?
assert_no_changes -> { users(:jason).reload.role } do
put user_role_path(users(:jason)), params: { user: { role: "admin" } }
end
assert_response :forbidden
end
test "admin cannot change owner role to member" do
assert users(:jason).owner?
assert_no_changes -> { users(:jason).reload.role } do
put user_role_path(users(:jason)), params: { user: { role: "member" } }
end
assert_response :forbidden
end
end
+14
View File
@@ -41,6 +41,20 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_nil User.active.find_by(id: users(:david).id)
end
test "admin cannot deactivate the owner" do
sign_in_as :kevin
assert users(:jason).owner?
assert users(:jason).active
assert_no_difference -> { User.active.count } do
delete user_path(users(:jason))
end
assert_response :forbidden
assert users(:jason).reload.active
end
test "non-admins cannot perform actions" do
sign_in_as :jz
+4
View File
@@ -5,6 +5,10 @@ david:
jz:
email_address: jz@37signals.com
jason:
email_address: jason@37signals.com
staff: true
kevin:
email_address: kevin@37signals.com
staff: true
+7
View File
@@ -12,6 +12,13 @@ jz:
identity: jz
account: 37s_uuid
jason:
id: <%= ActiveRecord::FixtureSet.identify("jason", :uuid) %>
name: Jason
role: owner
identity: jason
account: 37s_uuid
kevin:
id: <%= ActiveRecord::FixtureSet.identify("kevin", :uuid) %>
name: Kevin
@@ -39,4 +39,16 @@ class Account::ExternalIdSequenceTest < ActiveSupport::TestCase
assert_equal values.min..values.max, values.sort.first..values.sort.last
assert_equal 20, values.max - values.min + 1, "Values should be sequential with no gaps"
end
test "#value creates the first record if it doesn't yet exist" do
assert_nil Account::ExternalIdSequence.first
value = nil
assert_difference -> { Account::ExternalIdSequence.count }, 1 do
value = Account::ExternalIdSequence.value
end
assert_not_nil value
assert_equal value, Account::ExternalIdSequence.first.value
end
end
+11 -10
View File
@@ -12,17 +12,17 @@ class AccountTest < ActiveSupport::TestCase
assert_equal "/#{account.external_account_id}", account.slug
end
test ".create_with_admin_user creates a new local account" do
test ".create_with_owner creates a new local account" do
Current.without_account do
identity = identities(:david)
account = nil
assert_changes -> { Account.count }, +1 do
assert_changes -> { User.count }, +2 do
account = Account.create_with_admin_user(
account = Account.create_with_owner(
account: {
external_account_id: ActiveRecord::FixtureSet.identify("account-create-with-admin-user-test"),
name: "Account Create With Admin"
external_account_id: ActiveRecord::FixtureSet.identify("account-create-with-owner-test"),
name: "Account Create With Owner"
},
owner: {
name: "David",
@@ -34,13 +34,14 @@ class AccountTest < ActiveSupport::TestCase
assert_not_nil account
assert account.persisted?
assert_equal ActiveRecord::FixtureSet.identify("account-create-with-admin-user-test"), account.external_account_id
assert_equal "Account Create With Admin", account.name
assert_equal ActiveRecord::FixtureSet.identify("account-create-with-owner-test"), account.external_account_id
assert_equal "Account Create With Owner", account.name
admin = account.users.find_by(role: "admin")
assert_equal "David", admin.name
assert_equal "david@37signals.com", admin.identity.email_address
assert_equal "admin", admin.role
owner = account.users.find_by(role: "owner")
assert_equal "David", owner.name
assert_equal "david@37signals.com", owner.identity.email_address
assert_equal "owner", owner.role
assert owner.admin?, "owner should also be considered an admin"
assert_predicate account.system_user, :present?
end
+2 -2
View File
@@ -32,8 +32,8 @@ class MagicLinkTest < ActiveSupport::TestCase
magic_link = MagicLink.create!(identity: identities(:kevin))
code_with_spaces = magic_link.code.downcase.chars.join(" ")
identity = MagicLink.consume(code_with_spaces)
assert_equal identities(:kevin), identity
consumed_magic_link = MagicLink.consume(code_with_spaces)
assert_equal magic_link, consumed_magic_link
assert_not MagicLink.exists?(magic_link.id)
expired_link = MagicLink.create!(identity: identities(:kevin))
+50
View File
@@ -8,6 +8,56 @@ class User::RoleTest < ActiveSupport::TestCase
assert_not users(:jz).can_administer?(users(:kevin))
end
test "owner can administer admins and members" do
assert users(:jason).can_administer?(users(:kevin))
assert users(:jason).can_administer?(users(:david))
assert users(:jason).can_administer?(users(:jz))
end
test "owner cannot administer themselves" do
assert_not users(:jason).can_administer?(users(:jason))
end
test "admin cannot administer the owner" do
assert_not users(:kevin).can_administer?(users(:jason))
end
test "owner is included in active scope" do
active_users = User.active
assert_includes active_users, users(:jason)
assert_includes active_users, users(:kevin)
assert_includes active_users, users(:david)
assert_not_includes active_users, users(:system)
end
test "owner is also considered an admin" do
assert users(:jason).owner?
assert users(:jason).admin?
assert users(:kevin).admin?
assert_not users(:kevin).owner?
end
test "owner scope returns only active owners" do
owners = accounts("37s").users.owner
assert_includes owners, users(:jason)
assert_not_includes owners, users(:kevin)
assert_not_includes owners, users(:david)
users(:jason).update!(active: false)
assert_not_includes accounts("37s").users.owner, users(:jason)
end
test "admin scope returns active owners and admins" do
admins = accounts("37s").users.admin
assert_includes admins, users(:jason)
assert_includes admins, users(:kevin)
assert_not_includes admins, users(:david)
users(:kevin).update!(active: false)
assert_not_includes accounts("37s").users.admin, users(:kevin)
end
test "can administer board?" do
writebook_board = boards(:writebook)
private_board = boards(:private)