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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
class Signup::CompletionsController < ApplicationController
|
||||
class Signups::CompletionsController < ApplicationController
|
||||
layout "public"
|
||||
|
||||
disallow_account_scope
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ class Account::ExternalIdSequence < ApplicationRecord
|
||||
end
|
||||
|
||||
def value
|
||||
first&.value
|
||||
first&.value || self.next
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 we’ll 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 we’ll get you signed in.</p>
|
||||
|
||||
<button type="submit" id="log_in" class="btn btn--link center txt-medium">
|
||||
<span>Let’s go</span>
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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>Let’s go</span>
|
||||
<%= icon_tag "arrow-right" %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% content_for :footer do %>
|
||||
<%= render "sessions/footer" %>
|
||||
<% end %>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+4
@@ -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
|
||||
|
||||
Vendored
+7
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user