diff --git a/.github/workflows/ci-oss.yml b/.github/workflows/ci-oss.yml
index 7ccd00bf7..f6f490f34 100644
--- a/.github/workflows/ci-oss.yml
+++ b/.github/workflows/ci-oss.yml
@@ -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
diff --git a/.mcp.json b/.mcp.json
deleted file mode 100644
index 13beab928..000000000
--- a/.mcp.json
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/AGENTS.md b/AGENTS.md
index 8bdaa4481..203422200 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index fc1bcd758..f1c1b3b98 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -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)
diff --git a/README.md b/README.md
index a11dc6955..e88bca3c3 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/assets/images/system_user.png b/app/assets/images/system_user.png
index 5204feb44..0325d8a3d 100644
Binary files a/app/assets/images/system_user.png and b/app/assets/images/system_user.png differ
diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb
index 40de894df..dc55782af 100644
--- a/app/controllers/sessions/magic_links_controller.rb
+++ b/app/controllers/sessions/magic_links_controller.rb
@@ -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
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index aa765919d..72131e61c 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -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
diff --git a/app/controllers/signup/completions_controller.rb b/app/controllers/signups/completions_controller.rb
similarity index 88%
rename from app/controllers/signup/completions_controller.rb
rename to app/controllers/signups/completions_controller.rb
index d7f09c086..f0ea56ff9 100644
--- a/app/controllers/signup/completions_controller.rb
+++ b/app/controllers/signups/completions_controller.rb
@@ -1,4 +1,4 @@
-class Signup::CompletionsController < ApplicationController
+class Signups::CompletionsController < ApplicationController
layout "public"
disallow_account_scope
diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb
new file mode 100644
index 000000000..ea88383e0
--- /dev/null
+++ b/app/controllers/signups_controller.rb
@@ -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
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
new file mode 100644
index 000000000..7cccfe4ec
--- /dev/null
+++ b/app/helpers/users_helper.rb
@@ -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
diff --git a/app/models/account.rb b/app/models/account.rb
index 887ce708d..9c445f10e 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -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
diff --git a/app/models/account/external_id_sequence.rb b/app/models/account/external_id_sequence.rb
index 8d9cecf6d..3e76e1e24 100644
--- a/app/models/account/external_id_sequence.rb
+++ b/app/models/account/external_id_sequence.rb
@@ -8,7 +8,7 @@ class Account::ExternalIdSequence < ApplicationRecord
end
def value
- first&.value
+ first&.value || self.next
end
private
diff --git a/app/models/account/seedeable.rb b/app/models/account/seedeable.rb
index f0592020a..e50c22e96 100644
--- a/app/models/account/seedeable.rb
+++ b/app/models/account/seedeable.rb
@@ -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
diff --git a/app/models/identity.rb b/app/models/identity.rb
index e9ff507a1..135fdaae7 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -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
diff --git a/app/models/magic_link.rb b/app/models/magic_link.rb
index 69961e9a5..987ca776a 100644
--- a/app/models/magic_link.rb
+++ b/app/models/magic_link.rb
@@ -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
diff --git a/app/models/signup.rb b/app/models/signup.rb
index 230aafd0b..15ac5873c 100644
--- a/app/models/signup.rb
+++ b/app/models/signup.rb
@@ -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
diff --git a/app/models/user/role.rb b/app/models/user/role.rb
index 3c2101f68..e103e94d9 100644
--- a/app/models/user/role.rb
+++ b/app/models/user/role.rb
@@ -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)
diff --git a/app/views/account/settings/_user.html.erb b/app/views/account/settings/_user.html.erb
index 45fc77c07..6bc65acd8 100644
--- a/app/views/account/settings/_user.html.erb
+++ b/app/views/account/settings/_user.html.erb
@@ -10,11 +10,13 @@
<%= form_with model: user, url: user_role_path(user), data: { controller: "form" }, method: :patch do | form | %>
-
+
+
+
<% 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 %>
diff --git a/app/views/admin/stats/show.html.erb b/app/views/admin/stats/show.html.erb
index 2fe7ce71a..a3ae18c55 100644
--- a/app/views/admin/stats/show.html.erb
+++ b/app/views/admin/stats/show.html.erb
@@ -70,7 +70,7 @@
Please enter this 6-character verification code on the Fizzy sign-in page:
<% else %>
diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb
index 2d5ba8f8b..b21f00509 100644
--- a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb
+++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb
@@ -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:
diff --git a/app/views/sessions/menus/show.html+menu_section.erb b/app/views/sessions/menus/show.html+menu_section.erb
deleted file mode 100644
index e3468db65..000000000
--- a/app/views/sessions/menus/show.html+menu_section.erb
+++ /dev/null
@@ -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 %>
diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb
index 8a36261bf..9c6867740 100644
--- a/app/views/sessions/menus/show.html.erb
+++ b/app/views/sessions/menus/show.html.erb
@@ -25,7 +25,7 @@
<% end %>
- <%= 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 %>
Sign up for a new Fizzy account
<% end %>