organizations 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -0
- data/.rubocop_todo.yml +7 -0
- data/.simplecov +17 -10
- data/CHANGELOG.md +102 -0
- data/README.md +704 -33
- data/app/controllers/organizations/invitations_controller.rb +27 -22
- data/app/controllers/organizations/memberships_controller.rb +37 -30
- data/app/controllers/organizations/organizations_controller.rb +8 -8
- data/app/controllers/organizations/public_invitations_controller.rb +1 -1
- data/app/mailers/organizations/invitation_mailer.rb +2 -2
- data/app/mailers/organizations/verification_mailer.rb +1 -1
- data/app/models/organizations/grant.rb +6 -0
- data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
- data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
- data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
- data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
- data/config/locales/en.yml +22 -0
- data/config/locales/es.yml +22 -0
- data/config/routes.rb +1 -2
- data/gemfiles/rails_7.2.gemfile +5 -1
- data/gemfiles/rails_8.1.gemfile +5 -1
- data/lib/generators/organizations/grants/grants_generator.rb +47 -0
- data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
- data/lib/generators/organizations/install/install_generator.rb +1 -0
- data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
- data/lib/generators/organizations/join/join_generator.rb +113 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
- data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
- data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
- data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
- data/lib/generators/organizations/join/templates/routes.rb +19 -0
- data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
- data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
- data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
- data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
- data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
- data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
- data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
- data/lib/generators/organizations/views/views_generator.rb +4 -6
- data/lib/organizations/account_deletion_plan.rb +118 -0
- data/lib/organizations/configuration.rb +152 -0
- data/lib/organizations/controller_helpers.rb +55 -15
- data/lib/organizations/grant_access.rb +75 -0
- data/lib/organizations/grant_holder.rb +152 -0
- data/lib/organizations/grantable.rb +28 -0
- data/lib/organizations/join_flow.rb +16 -12
- data/lib/organizations/models/concerns/has_organizations.rb +224 -9
- data/lib/organizations/models/domain.rb +283 -1
- data/lib/organizations/models/grant.rb +152 -0
- data/lib/organizations/models/invitation.rb +76 -7
- data/lib/organizations/models/join_request.rb +102 -43
- data/lib/organizations/models/membership.rb +87 -5
- data/lib/organizations/models/organization.rb +219 -48
- data/lib/organizations/organization_scoped.rb +16 -0
- data/lib/organizations/personal_owner_backfill.rb +122 -0
- data/lib/organizations/public_email_domains.rb +49 -0
- data/lib/organizations/resource.rb +11 -0
- data/lib/organizations/roles.rb +55 -0
- data/lib/organizations/version.rb +1 -1
- data/lib/organizations/view_helpers.rb +40 -9
- data/lib/organizations.rb +99 -4
- metadata +22 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Scoped resource grants: a membership (or a pending invitation) limited to
|
|
4
|
+
# some records of your app, with a capability list per record.
|
|
5
|
+
class CreateOrganizationsGrants < ActiveRecord::Migration<%= migration_version %>
|
|
6
|
+
def change
|
|
7
|
+
# ⚠️ If your user model is NOT `User` (config.user_class), change
|
|
8
|
+
# `to_table: :users` to your user table before migrating.
|
|
9
|
+
foreign_key_type = primary_key_type_setting || :bigint
|
|
10
|
+
|
|
11
|
+
create_table :organizations_grants, id: primary_key_type_setting || :primary_key do |t|
|
|
12
|
+
# Exactly one holder: the membership, or the invitation until it is accepted.
|
|
13
|
+
t.references :membership, null: true, type: foreign_key_type,
|
|
14
|
+
foreign_key: { to_table: :organizations_memberships, on_delete: :cascade }
|
|
15
|
+
t.references :invitation, null: true, type: foreign_key_type,
|
|
16
|
+
foreign_key: { to_table: :organizations_invitations, on_delete: :cascade }
|
|
17
|
+
|
|
18
|
+
# The granted resource. ⚠️ grantable_id must have the type of the ids
|
|
19
|
+
# you grant: the app's primary key type by default. Use :uuid or
|
|
20
|
+
# :string if your resources use other ids.
|
|
21
|
+
t.string :grantable_type, null: false
|
|
22
|
+
t.column :grantable_id, foreign_key_type, null: false
|
|
23
|
+
|
|
24
|
+
t.send(json_column_type, :capabilities, null: json_column_null, default: json_column_default)
|
|
25
|
+
t.references :granted_by, null: true, type: foreign_key_type,
|
|
26
|
+
foreign_key: { to_table: :users, on_delete: :nullify }
|
|
27
|
+
|
|
28
|
+
t.timestamps
|
|
29
|
+
|
|
30
|
+
t.check_constraint "(membership_id IS NULL) <> (invitation_id IS NULL)", name: "org_grants_one_holder"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
add_index :organizations_grants, [ :membership_id, :grantable_type, :grantable_id ],
|
|
34
|
+
unique: true, name: "index_org_grants_on_membership_and_grantable"
|
|
35
|
+
add_index :organizations_grants, [ :invitation_id, :grantable_type, :grantable_id ],
|
|
36
|
+
unique: true, name: "index_org_grants_on_invitation_and_grantable"
|
|
37
|
+
add_index :organizations_grants, [ :grantable_type, :grantable_id ], name: "index_org_grants_on_grantable"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def primary_key_type_setting
|
|
43
|
+
config = Rails.configuration.generators
|
|
44
|
+
config.options[config.orm][:primary_key_type]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def json_column_type
|
|
48
|
+
connection.adapter_name.downcase.include?("postgresql") ? :jsonb : :json
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# MySQL 8+ doesn't allow default values on JSON columns.
|
|
52
|
+
def json_column_default
|
|
53
|
+
connection.adapter_name.downcase.include?("mysql") ? nil : []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def json_column_null
|
|
57
|
+
connection.adapter_name.downcase.include?("mysql")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -33,6 +33,7 @@ module Organizations
|
|
|
33
33
|
say " 5. Run 'rails g organizations:views' to copy the reference views"
|
|
34
34
|
say " (the engine renders HOST views — without them you'll hit"
|
|
35
35
|
say " missing-template errors on the org/membership/invitation pages)."
|
|
36
|
+
say " 6. Optional: run 'rails g organizations:join' for the join and access screens."
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
private
|
|
@@ -13,6 +13,12 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
13
13
|
t.string :name, null: false
|
|
14
14
|
t.integer :memberships_count, default: 0, null: false
|
|
15
15
|
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
16
|
+
# The user whose personal organization this is (nil for every other
|
|
17
|
+
# organization). Unique: one personal organization per user. The row
|
|
18
|
+
# clears itself if the user row is deleted outside the gem.
|
|
19
|
+
# (`to_table: :users`: see the note at the top of this migration.)
|
|
20
|
+
t.references :personal_owner, null: true, type: foreign_key_type, index: { unique: true },
|
|
21
|
+
foreign_key: { to_table: :users, on_delete: :nullify }
|
|
16
22
|
|
|
17
23
|
t.timestamps
|
|
18
24
|
end
|
|
@@ -126,6 +132,15 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
|
|
|
126
132
|
t.string :domain, null: false
|
|
127
133
|
t.send(json_column_type, :membership_metadata, null: json_column_null, default: json_column_default)
|
|
128
134
|
t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
|
|
135
|
+
# Domain authority: the sign-up policy (none|suggest|auto_join) and the
|
|
136
|
+
# proof of control (DNS TXT challenge or operator approval).
|
|
137
|
+
t.string :join_policy, null: false, default: "none"
|
|
138
|
+
t.string :verification_token
|
|
139
|
+
t.datetime :verification_started_at
|
|
140
|
+
t.datetime :verified_at
|
|
141
|
+
t.string :verification_method
|
|
142
|
+
t.references :verified_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users, on_delete: :nullify }
|
|
143
|
+
t.datetime :verification_revoked_at
|
|
129
144
|
|
|
130
145
|
t.timestamps
|
|
131
146
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Organizations
|
|
6
|
+
module Generators
|
|
7
|
+
# Copies the join screen and the access screen into the host app, with
|
|
8
|
+
# their routes: host-owned controllers and views around JoinFlow and
|
|
9
|
+
# JoinState, in the style of `rails g organizations:views`.
|
|
10
|
+
#
|
|
11
|
+
# rails g organizations:join # join screen + access screen + routes
|
|
12
|
+
# rails g organizations:join --skip-access # the join screen only
|
|
13
|
+
# rails g organizations:join --skip-routes # add the routes yourself
|
|
14
|
+
#
|
|
15
|
+
# The templates are byte-identical to the gem's test/dummy app, where an
|
|
16
|
+
# HTTP test suite covers them. The engine adds no route: nothing changes
|
|
17
|
+
# in an app until it runs this generator.
|
|
18
|
+
class JoinGenerator < Rails::Generators::Base
|
|
19
|
+
source_root File.expand_path("templates", __dir__)
|
|
20
|
+
desc "Copies the organization join and access screens (controllers, views, routes) into your app"
|
|
21
|
+
|
|
22
|
+
class_option :skip_access, type: :boolean, default: false,
|
|
23
|
+
desc: "Skip the access screen (domains, codes, allowlist, request queue)"
|
|
24
|
+
class_option :skip_routes, type: :boolean, default: false, desc: "Do not add the routes"
|
|
25
|
+
|
|
26
|
+
JOIN_FILES = %w[
|
|
27
|
+
app/controllers/organization_joins_controller.rb
|
|
28
|
+
app/views/organization_joins/show.html.erb
|
|
29
|
+
app/views/organization_joins/_disclosure.html.erb
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
ROUTES_MARKER = "# organizations: join and access screens"
|
|
33
|
+
DRAW_ANCHOR = /\.routes\.draw do\s*\n/
|
|
34
|
+
ACCESS_ANCHOR = /as: :withdraw_join\n/
|
|
35
|
+
|
|
36
|
+
ACCESS_FILES = %w[
|
|
37
|
+
app/controllers/organization_access_controller.rb
|
|
38
|
+
app/views/organization_access/show.html.erb
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# The routes block, without the access routes when access: false.
|
|
42
|
+
# @return [String]
|
|
43
|
+
def self.routes_snippet(access: true)
|
|
44
|
+
snippet = File.read(File.join(source_root, "routes.rb"))
|
|
45
|
+
return snippet if access
|
|
46
|
+
|
|
47
|
+
snippet.lines.reject { |line| line.include?("organization_access#") }.join.gsub(/\n\n+(\s*end)/, "\n\\1")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Only the access routes of the block.
|
|
51
|
+
# @return [String]
|
|
52
|
+
def self.access_routes
|
|
53
|
+
File.read(File.join(source_root, "routes.rb")).lines.select { |line| line.include?("organization_access#") }.join
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def copy_join_screen
|
|
57
|
+
JOIN_FILES.each { |file| copy_file file }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def copy_access_screen
|
|
61
|
+
ACCESS_FILES.each { |file| copy_file file } unless options[:skip_access]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Adds the routes block once. A second run adds only what is missing:
|
|
65
|
+
# the access routes after an earlier --skip-access run.
|
|
66
|
+
def add_routes
|
|
67
|
+
return if options[:skip_routes]
|
|
68
|
+
|
|
69
|
+
routes = read_routes
|
|
70
|
+
access = !options[:skip_access]
|
|
71
|
+
if !routes.include?(ROUTES_MARKER)
|
|
72
|
+
insert_routes(self.class.routes_snippet(access: access), DRAW_ANCHOR, routes)
|
|
73
|
+
elsif access && !active_code(routes).include?("organization_access#")
|
|
74
|
+
insert_routes(self.class.access_routes, ACCESS_ANCHOR, routes)
|
|
75
|
+
else
|
|
76
|
+
say_status :identical, "config/routes.rb (the organizations routes are already there)", :blue
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
no_commands do
|
|
81
|
+
# Insert after the anchor, or print the routes when the anchor is not
|
|
82
|
+
# there (an edited routes file): never a silent no-op.
|
|
83
|
+
def insert_routes(snippet, anchor, routes)
|
|
84
|
+
if routes.match?(anchor)
|
|
85
|
+
inject_into_file "config/routes.rb", "\n#{snippet}", after: anchor
|
|
86
|
+
else
|
|
87
|
+
say_status :skip, "config/routes.rb: add these routes yourself", :yellow
|
|
88
|
+
say snippet
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def read_routes = File.read(File.join(destination_root, "config/routes.rb"))
|
|
93
|
+
|
|
94
|
+
# The routes file without comment lines.
|
|
95
|
+
def active_code(routes)
|
|
96
|
+
routes.lines.reject { |line| line.lstrip.start_with?("#") }.join
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def show_readme
|
|
101
|
+
say ""
|
|
102
|
+
say "Organization join screen copied: GET /orgs/:organization_id/join (organization_join_path).", :green
|
|
103
|
+
unless options[:skip_access]
|
|
104
|
+
say "Access screen copied: GET /orgs/:organization_id/access (organization_access_path)."
|
|
105
|
+
end
|
|
106
|
+
say "These views use Tailwind classes and the `heroicon` helper (gem \"heroicons\")."
|
|
107
|
+
say "Before you ship: read the safety rules at the top of each controller,"
|
|
108
|
+
say "check the rate limits, and edit the disclosure text to match your privacy policy."
|
|
109
|
+
say ""
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Who can join one organization, and how: email domains (with domain
|
|
4
|
+
# verification and the sign-up policy), join codes, the allowlist, and the
|
|
5
|
+
# queue of pending join requests. Every change goes through the gem's
|
|
6
|
+
# models, never raw row writes.
|
|
7
|
+
#
|
|
8
|
+
# Generated by `rails g organizations:join`. It is yours now: change the
|
|
9
|
+
# copy, the layout and the URL. Keep the safety rules below.
|
|
10
|
+
#
|
|
11
|
+
# Safety rules:
|
|
12
|
+
# - Organizations::OrganizationScoped takes the organization from the URL,
|
|
13
|
+
# and every lookup below is scoped to it, so an id from another
|
|
14
|
+
# organization is a 404.
|
|
15
|
+
# - The page needs the invite_members permission; domain changes also need
|
|
16
|
+
# manage_settings. Unknown organizations, strangers and members without
|
|
17
|
+
# the permission all get the same 404, so the page reveals nothing.
|
|
18
|
+
# - Only the domain name and the join policy come from params. The
|
|
19
|
+
# verification columns are written by the gem's methods only.
|
|
20
|
+
# - Lists from params are cut to a fixed size.
|
|
21
|
+
# - DNS checks are rate limited (Rails 7.2+).
|
|
22
|
+
class OrganizationAccessController < ApplicationController
|
|
23
|
+
include Organizations::OrganizationScoped
|
|
24
|
+
|
|
25
|
+
MAX_ALLOWLIST_IMPORT = 1_000
|
|
26
|
+
MAX_ALLOWLIST_INPUT = 300_000 # characters, about 1,000 long addresses
|
|
27
|
+
MAX_LABEL_LENGTH = 100
|
|
28
|
+
|
|
29
|
+
before_action -> { send(Organizations.configuration.authenticate_user_method) }
|
|
30
|
+
self.organization_param = :organization_id
|
|
31
|
+
require_organization_permission :invite_members
|
|
32
|
+
require_organization_permission :manage_settings, only: %i[add_domain update_domain remove_domain
|
|
33
|
+
start_domain_verification verify_domain]
|
|
34
|
+
|
|
35
|
+
# Rails 7.2+, with a shared cache store (see OrganizationJoinsController).
|
|
36
|
+
rate_limit to: 10, within: 10.minutes, by: -> { viewer.id }, only: :verify_domain if respond_to?(:rate_limit)
|
|
37
|
+
|
|
38
|
+
helper_method :organization
|
|
39
|
+
|
|
40
|
+
def show
|
|
41
|
+
@domains = organization.domains.order(:domain)
|
|
42
|
+
@join_codes = organization.join_codes.order(created_at: :desc)
|
|
43
|
+
@allowlist_entries = organization.allowlist_entries.unclaimed.order(:email)
|
|
44
|
+
@pending_requests = organization.join_requests.pending.includes(:user).order(created_at: :asc)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def add_domain
|
|
48
|
+
organization.add_domain!(params[:domain].to_s.first(253))
|
|
49
|
+
back notice: "Domain added. Verify it before it can admit anyone when verification is required."
|
|
50
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
51
|
+
back alert: e.message
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def update_domain
|
|
55
|
+
domain.update!(join_policy: params[:join_policy].to_s)
|
|
56
|
+
back notice: "Join policy saved. It acts once the domain is verified."
|
|
57
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
58
|
+
back alert: e.message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def remove_domain
|
|
62
|
+
domain.destroy!
|
|
63
|
+
back notice: "Domain removed. Existing members keep their membership."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def start_domain_verification
|
|
67
|
+
domain.start_verification!
|
|
68
|
+
back notice: "Add the TXT record below to your DNS, then press Verify."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def verify_domain
|
|
72
|
+
domain.verify!
|
|
73
|
+
back notice: "#{domain.domain} is verified."
|
|
74
|
+
rescue Organizations::DomainVerificationFailed => e
|
|
75
|
+
back alert: e.message
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def generate_code
|
|
79
|
+
code = organization.generate_join_code!(
|
|
80
|
+
label: params[:label].to_s.first(MAX_LABEL_LENGTH).presence,
|
|
81
|
+
auto_approve: params[:auto_approve] == "1",
|
|
82
|
+
created_by: viewer
|
|
83
|
+
)
|
|
84
|
+
back notice: "Code #{code.display_code} created."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def revoke_code
|
|
88
|
+
organization.join_codes.find(params[:code_id]).revoke!
|
|
89
|
+
back notice: "Code revoked. To rotate a code, revoke it and create a new one."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Valid addresses are imported; the rest are counted and skipped.
|
|
93
|
+
def import_allowlist
|
|
94
|
+
tokens = params[:emails].to_s.first(MAX_ALLOWLIST_INPUT).split(/[\s,;]+/, MAX_ALLOWLIST_IMPORT + 1)
|
|
95
|
+
tokens = tokens.compact_blank.first(MAX_ALLOWLIST_IMPORT)
|
|
96
|
+
valid, invalid = tokens.partition { |email| email.length <= 254 && email.match?(URI::MailTo::EMAIL_REGEXP) }
|
|
97
|
+
imported = organization.import_allowlist!(valid, source: "manual")
|
|
98
|
+
message = "#{imported.size} address(es) added to the allowlist."
|
|
99
|
+
message += " #{invalid.size} skipped: not an email address." if invalid.any?
|
|
100
|
+
back notice: message
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def remove_allowlist_entry
|
|
104
|
+
organization.allowlist_entries.unclaimed.find(params[:entry_id]).destroy!
|
|
105
|
+
back notice: "Address removed from the allowlist."
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Hard limits (seats) belong in config.on_member_joining: the gate also
|
|
109
|
+
# covers this path, so there is no check to repeat here.
|
|
110
|
+
def approve_request
|
|
111
|
+
join_request = organization.join_requests.find(params[:request_id])
|
|
112
|
+
organization.approve_join_request!(join_request, approved_by: viewer)
|
|
113
|
+
back notice: "#{join_request.user.email} is now a member."
|
|
114
|
+
rescue Organizations::MembershipVetoed, Organizations::JoinRequestError => e
|
|
115
|
+
back alert: e.message
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def reject_request
|
|
119
|
+
join_request = organization.join_requests.find(params[:request_id])
|
|
120
|
+
reason = params[:reason].to_s.first(500).presence
|
|
121
|
+
organization.reject_join_request!(join_request, rejected_by: viewer, reason: reason)
|
|
122
|
+
back notice: "Request rejected."
|
|
123
|
+
rescue Organizations::JoinRequestError => e
|
|
124
|
+
back alert: e.message
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def organization
|
|
130
|
+
current_scoped_organization
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The signed-in user, through config.current_user_method.
|
|
134
|
+
def viewer
|
|
135
|
+
send(Organizations.configuration.current_user_method)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def domain
|
|
139
|
+
@domain ||= organization.domains.find(params[:domain_id])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# 303, so a browser follows a POST or DELETE with a GET.
|
|
143
|
+
def back(**messages)
|
|
144
|
+
redirect_to organization_access_path(organization), status: :see_other, **messages
|
|
145
|
+
end
|
|
146
|
+
end
|
data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The join screen of one organization: every way in (join code, work email,
|
|
4
|
+
# request to join) at one URL, rendered by Organizations::JoinState and
|
|
5
|
+
# driven by Organizations::JoinFlow.
|
|
6
|
+
#
|
|
7
|
+
# Generated by `rails g organizations:join`. It is yours now: change the
|
|
8
|
+
# copy, the layout and the URL. Keep the safety rules below.
|
|
9
|
+
#
|
|
10
|
+
# GET /orgs/:organization_id/join the screen (a ?code=... link only fills in the form)
|
|
11
|
+
# POST /orgs/:organization_id/join a code, an email, a 6-digit code, or a plain request
|
|
12
|
+
# DELETE /orgs/:organization_id/join withdraw the pending request
|
|
13
|
+
#
|
|
14
|
+
# Safety rules:
|
|
15
|
+
# - A signed-in user is required: JoinFlow needs one.
|
|
16
|
+
# - Every change is a POST or a DELETE, with Rails' CSRF protection
|
|
17
|
+
# (ApplicationController's protect_from_forgery). A GET never joins and
|
|
18
|
+
# never spends a code, also after a sign-in redirect.
|
|
19
|
+
# - Inputs are cut to a fixed length before they reach the gem.
|
|
20
|
+
# - Unknown, revoked, expired and foreign codes get one generic message
|
|
21
|
+
# (JoinFlow does that). Do not tell users which codes exist.
|
|
22
|
+
# - The current organization of the session does not change here.
|
|
23
|
+
# - Any signed-in user who has an organization's id sees its name here.
|
|
24
|
+
# For private tenants, use UUID primary keys or a handle finder.
|
|
25
|
+
# - Rate limits (Rails 7.2+) cap guessing across requests. The gem itself
|
|
26
|
+
# throttles each emailed challenge.
|
|
27
|
+
# They need Rails 7.2+ and a cache store shared by every server process
|
|
28
|
+
# (Redis, Solid Cache): the null and memory stores do not enforce them
|
|
29
|
+
# across processes. On Rails 7.1, use Rack::Attack.
|
|
30
|
+
# - The disclosure partial tells joiners who will see their data. Keep it
|
|
31
|
+
# visible before any submit.
|
|
32
|
+
# - A sent code or a pending request redirects (303), as Turbo expects.
|
|
33
|
+
# Failures render with 422.
|
|
34
|
+
class OrganizationJoinsController < ApplicationController
|
|
35
|
+
MAX_CODE_LENGTH = 32
|
|
36
|
+
MAX_EMAIL_LENGTH = 254
|
|
37
|
+
MAX_VERIFICATION_CODE_LENGTH = 12
|
|
38
|
+
MAX_MESSAGE_LENGTH = 500
|
|
39
|
+
|
|
40
|
+
before_action -> { send(Organizations.configuration.authenticate_user_method) }
|
|
41
|
+
before_action :set_organization
|
|
42
|
+
|
|
43
|
+
if respond_to?(:rate_limit)
|
|
44
|
+
rate_limit to: 10, within: 1.hour, by: -> { viewer.id }, only: :create
|
|
45
|
+
rate_limit to: 60, within: 1.minute, by: -> { request.remote_ip }, only: :show
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def show
|
|
49
|
+
@state = Organizations::JoinState.for(user: viewer, organization: @organization)
|
|
50
|
+
@staged_code = bounded(params[:code], MAX_CODE_LENGTH)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def create
|
|
54
|
+
result = Organizations::JoinFlow.attempt(
|
|
55
|
+
user: viewer,
|
|
56
|
+
organization: @organization,
|
|
57
|
+
code: bounded(params[:code], MAX_CODE_LENGTH),
|
|
58
|
+
email: bounded(params[:email], MAX_EMAIL_LENGTH),
|
|
59
|
+
verification_code: bounded(params[:verification_code], MAX_VERIFICATION_CODE_LENGTH),
|
|
60
|
+
message: bounded(params[:message], MAX_MESSAGE_LENGTH)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if result.failed?
|
|
64
|
+
@state = Organizations::JoinState.for(user: viewer, organization: @organization, result: result)
|
|
65
|
+
flash.now[:alert] = @state.error_message
|
|
66
|
+
render :show, status: 422
|
|
67
|
+
else
|
|
68
|
+
# The screen reads the new state (member, verifying, pending) on the GET.
|
|
69
|
+
notice = result.member? ? "Welcome to #{@organization.name}!" : nil
|
|
70
|
+
redirect_to organization_join_path(@organization), status: :see_other, notice: notice
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def withdraw
|
|
75
|
+
viewer.pending_join_request_for(@organization)&.withdraw!
|
|
76
|
+
redirect_to organization_join_path(@organization), status: :see_other, notice: "Request withdrawn."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def set_organization
|
|
82
|
+
@organization = Organizations::Organization.find(params[:organization_id])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The signed-in user, through config.current_user_method.
|
|
86
|
+
def viewer
|
|
87
|
+
send(Organizations.configuration.current_user_method)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def bounded(value, limit)
|
|
91
|
+
value.to_s.strip.first(limit).presence
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<%# Who can join, and how: pending requests, email domains (with domain
|
|
2
|
+
verification and the sign-up policy), join codes and the allowlist.
|
|
3
|
+
Generated by `rails g organizations:join`. Every form posts to
|
|
4
|
+
OrganizationAccessController, which changes data through the gem only. %>
|
|
5
|
+
|
|
6
|
+
<div class="mb-8">
|
|
7
|
+
<h1 class="text-2xl font-bold text-gray-900">Access — <%= organization.name %></h1>
|
|
8
|
+
<p class="mt-1 text-sm text-gray-500">Who can join, and how they prove they belong.</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<div class="space-y-8">
|
|
12
|
+
<%# ── Pending requests ─────────────────────────────────────────────── %>
|
|
13
|
+
<section class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
14
|
+
<h2 class="text-lg font-semibold text-gray-900">Pending requests</h2>
|
|
15
|
+
<% if @pending_requests.blank? %>
|
|
16
|
+
<p class="mt-2 text-sm text-gray-500">Nobody is waiting.</p>
|
|
17
|
+
<% else %>
|
|
18
|
+
<ul class="mt-4 divide-y divide-gray-100">
|
|
19
|
+
<% @pending_requests.each do |request| %>
|
|
20
|
+
<li class="flex items-center justify-between gap-4 py-3">
|
|
21
|
+
<div>
|
|
22
|
+
<div class="text-sm font-medium text-gray-900"><%= request.user.email %></div>
|
|
23
|
+
<div class="text-xs text-gray-500">
|
|
24
|
+
<%= request.email_verified? ? "verified #{request.verification_email}" : "unverified" %>
|
|
25
|
+
· <%= request.message.presence || "no message" %>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="flex gap-2">
|
|
29
|
+
<%= button_to "Approve", organization_approve_access_request_path(organization, request_id: request.id),
|
|
30
|
+
method: :post, class: "rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-700 cursor-pointer" %>
|
|
31
|
+
<%= button_to "Reject", organization_reject_access_request_path(organization, request_id: request.id),
|
|
32
|
+
method: :post, class: "rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 cursor-pointer" %>
|
|
33
|
+
</div>
|
|
34
|
+
</li>
|
|
35
|
+
<% end %>
|
|
36
|
+
</ul>
|
|
37
|
+
<% end %>
|
|
38
|
+
</section>
|
|
39
|
+
|
|
40
|
+
<%# ── Email domains ─────────────────────────────────────────────────── %>
|
|
41
|
+
<section class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
42
|
+
<h2 class="text-lg font-semibold text-gray-900">Email domains</h2>
|
|
43
|
+
<p class="mt-1 text-sm text-gray-500">Anyone proving an inbox under these domains can join (emailed 6-digit code, or instantly with a confirmed account email). Verify each domain to prove the organization controls it.</p>
|
|
44
|
+
<ul class="mt-4 space-y-3">
|
|
45
|
+
<% @domains.each do |domain| %>
|
|
46
|
+
<li class="rounded-lg bg-gray-50 px-3 py-2">
|
|
47
|
+
<div class="flex items-center justify-between gap-2">
|
|
48
|
+
<div>
|
|
49
|
+
<code class="text-sm text-gray-900">@<%= domain.domain %></code>
|
|
50
|
+
<span class="ml-2 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium <%= domain.verified? ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600" %>">
|
|
51
|
+
<%= if domain.verified? then "verified" elsif domain.revoked? then "revoked" else "not verified" end %>
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
<%= button_to "Remove", organization_access_domain_path(organization, domain_id: domain.id),
|
|
55
|
+
method: :delete, class: "text-xs font-medium text-gray-500 hover:text-red-600 cursor-pointer bg-transparent" %>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<%# Domain verification: a TXT record proves that the organization controls the domain. %>
|
|
59
|
+
<% unless domain.verified? %>
|
|
60
|
+
<div class="mt-2 text-xs text-gray-600">
|
|
61
|
+
<% if domain.verification_record_value && !domain.verification_expired? %>
|
|
62
|
+
<p>Add this TXT record to your DNS, then press Verify:</p>
|
|
63
|
+
<p class="mt-1"><code class="break-all"><%= domain.verification_record_name %></code></p>
|
|
64
|
+
<p><code class="break-all"><%= domain.verification_record_value %></code></p>
|
|
65
|
+
<%= button_to "Verify", organization_verify_access_domain_path(organization, domain_id: domain.id),
|
|
66
|
+
method: :post, class: "mt-2 rounded-lg bg-slate-600 px-3 py-1 text-xs font-semibold text-white hover:bg-slate-700 cursor-pointer" %>
|
|
67
|
+
<% else %>
|
|
68
|
+
<%= button_to "Start verification", organization_access_domain_verification_path(organization, domain_id: domain.id),
|
|
69
|
+
method: :post, class: "rounded-lg border border-slate-600 px-3 py-1 text-xs font-semibold text-slate-700 hover:bg-white cursor-pointer" %>
|
|
70
|
+
<% end %>
|
|
71
|
+
</div>
|
|
72
|
+
<% end %>
|
|
73
|
+
|
|
74
|
+
<%# The sign-up policy acts only once the domain is verified. %>
|
|
75
|
+
<%= form_with url: organization_access_domain_path(organization, domain_id: domain.id), method: :patch, class: "mt-2 flex items-center gap-2 text-xs" do |form| %>
|
|
76
|
+
<label class="text-gray-600">At sign-up:</label>
|
|
77
|
+
<%= form.select :join_policy,
|
|
78
|
+
[["Do nothing", "none"], ["Suggest joining", "suggest"], ["Join automatically", "auto_join"]],
|
|
79
|
+
{ selected: domain.join_policy }, class: "rounded-lg border-gray-300 text-xs" %>
|
|
80
|
+
<%= form.submit "Save", class: "font-medium text-slate-600 hover:text-slate-800 cursor-pointer bg-transparent border-0 p-0" %>
|
|
81
|
+
<% end %>
|
|
82
|
+
</li>
|
|
83
|
+
<% end %>
|
|
84
|
+
</ul>
|
|
85
|
+
<%= form_with url: organization_access_domains_path(organization), method: :post, class: "mt-4 flex gap-2" do |form| %>
|
|
86
|
+
<%= form.text_field :domain, placeholder: "company.com", required: true, maxlength: 253,
|
|
87
|
+
class: "block w-full rounded-lg border-gray-300 text-sm focus:border-slate-500 focus:ring-slate-500" %>
|
|
88
|
+
<%= form.submit "Add", class: "rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700 cursor-pointer" %>
|
|
89
|
+
<% end %>
|
|
90
|
+
</section>
|
|
91
|
+
|
|
92
|
+
<%# ── Join codes ────────────────────────────────────────────────────── %>
|
|
93
|
+
<section class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
94
|
+
<h2 class="text-lg font-semibold text-gray-900">Join codes</h2>
|
|
95
|
+
<p class="mt-1 text-sm text-gray-500">Shareable PINs for posters, chats, and onboarding docs. Rotation = revoke + create a new one.</p>
|
|
96
|
+
<ul class="mt-4 space-y-2">
|
|
97
|
+
<% @join_codes.each do |code| %>
|
|
98
|
+
<li class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2">
|
|
99
|
+
<div>
|
|
100
|
+
<code class="text-sm font-semibold tracking-widest text-gray-900"><%= code.display_code %></code>
|
|
101
|
+
<span class="ml-2 text-xs text-gray-500"><%= code.label %></span>
|
|
102
|
+
<span class="ml-2 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium <%= code.active? ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600" %>">
|
|
103
|
+
<%= code.status %> · <%= code.uses_count %> uses
|
|
104
|
+
</span>
|
|
105
|
+
</div>
|
|
106
|
+
<% if code.active? %>
|
|
107
|
+
<%= button_to "Revoke", organization_revoke_access_code_path(organization, code_id: code.id),
|
|
108
|
+
method: :post, class: "text-xs font-medium text-gray-500 hover:text-red-600 cursor-pointer bg-transparent" %>
|
|
109
|
+
<% end %>
|
|
110
|
+
</li>
|
|
111
|
+
<% end %>
|
|
112
|
+
</ul>
|
|
113
|
+
<%= form_with url: organization_access_codes_path(organization), method: :post, class: "mt-4 flex items-center gap-2" do |form| %>
|
|
114
|
+
<%= form.text_field :label, placeholder: "Label (e.g. onboarding doc)", maxlength: 100,
|
|
115
|
+
class: "block w-full rounded-lg border-gray-300 text-sm focus:border-slate-500 focus:ring-slate-500" %>
|
|
116
|
+
<label class="flex shrink-0 items-center gap-1 text-xs text-gray-600">
|
|
117
|
+
<%= form.check_box :auto_approve, { checked: true }, "1", "0" %> auto-approve
|
|
118
|
+
</label>
|
|
119
|
+
<%= form.submit "Create", class: "rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700 cursor-pointer" %>
|
|
120
|
+
<% end %>
|
|
121
|
+
</section>
|
|
122
|
+
|
|
123
|
+
<%# ── Allowlist ─────────────────────────────────────────────────────── %>
|
|
124
|
+
<section class="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
125
|
+
<h2 class="text-lg font-semibold text-gray-900">Allowlist</h2>
|
|
126
|
+
<p class="mt-1 text-sm text-gray-500">Exact addresses (rosters, mixed-provider orgs). Rostered users still verify their inbox — a leaked roster grants nothing.</p>
|
|
127
|
+
<ul class="mt-4 flex flex-wrap gap-2">
|
|
128
|
+
<% @allowlist_entries.each do |entry| %>
|
|
129
|
+
<li class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-700">
|
|
130
|
+
<%= entry.email %>
|
|
131
|
+
<%= button_to "×", organization_access_allowlist_entry_path(organization, entry_id: entry.id), method: :delete,
|
|
132
|
+
title: "Remove", class: "text-gray-400 hover:text-red-600 cursor-pointer bg-transparent" %>
|
|
133
|
+
</li>
|
|
134
|
+
<% end %>
|
|
135
|
+
</ul>
|
|
136
|
+
<%= form_with url: organization_access_allowlist_path(organization), method: :post, class: "mt-4 space-y-2" do |form| %>
|
|
137
|
+
<%= form.text_area :emails, rows: 2, placeholder: "ana@gmail.com, luis@yahoo.es …",
|
|
138
|
+
class: "block w-full rounded-lg border-gray-300 text-sm focus:border-slate-500 focus:ring-slate-500" %>
|
|
139
|
+
<%= form.submit "Import", class: "rounded-lg bg-slate-600 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700 cursor-pointer" %>
|
|
140
|
+
<% end %>
|
|
141
|
+
</section>
|
|
142
|
+
</div>
|
data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%# Who will see what. Tell joiners before they submit anything: the
|
|
2
|
+
admins of the organization see their name, their account email and the
|
|
3
|
+
email address they verify (the access screen shows all three), and a
|
|
4
|
+
pending request keeps that address until it is decided.
|
|
5
|
+
Change the text to match your product and your privacy policy. %>
|
|
6
|
+
<p class="mt-1 text-xs text-gray-500">
|
|
7
|
+
The admins of <%= organization.name %> will see your name, your account email, and the email address you use to join.
|
|
8
|
+
</p>
|