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 @@