organizations 0.5.0 → 0.7.0

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -0
  3. data/.rubocop_todo.yml +7 -0
  4. data/.simplecov +17 -10
  5. data/Appraisals +2 -0
  6. data/CHANGELOG.md +112 -0
  7. data/README.md +714 -32
  8. data/app/controllers/organizations/invitations_controller.rb +27 -22
  9. data/app/controllers/organizations/memberships_controller.rb +37 -30
  10. data/app/controllers/organizations/organizations_controller.rb +8 -8
  11. data/app/controllers/organizations/public_invitations_controller.rb +1 -1
  12. data/app/mailers/organizations/invitation_mailer.rb +2 -2
  13. data/app/mailers/organizations/verification_mailer.rb +1 -1
  14. data/app/models/organizations/grant.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +11 -11
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +7 -7
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +3 -3
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +3 -3
  19. data/config/locales/en.yml +22 -0
  20. data/config/locales/es.yml +22 -0
  21. data/config/routes.rb +1 -2
  22. data/gemfiles/rails_7.2.gemfile +6 -1
  23. data/gemfiles/rails_8.1.gemfile +5 -1
  24. data/lib/generators/organizations/grants/grants_generator.rb +47 -0
  25. data/lib/generators/organizations/grants/templates/create_organizations_grants.rb.erb +59 -0
  26. data/lib/generators/organizations/install/install_generator.rb +1 -0
  27. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +15 -0
  28. data/lib/generators/organizations/join/join_generator.rb +113 -0
  29. data/lib/generators/organizations/join/templates/app/controllers/organization_access_controller.rb +146 -0
  30. data/lib/generators/organizations/join/templates/app/controllers/organization_joins_controller.rb +93 -0
  31. data/lib/generators/organizations/join/templates/app/views/organization_access/show.html.erb +142 -0
  32. data/lib/generators/organizations/join/templates/app/views/organization_joins/_disclosure.html.erb +8 -0
  33. data/lib/generators/organizations/join/templates/app/views/organization_joins/show.html.erb +113 -0
  34. data/lib/generators/organizations/join/templates/routes.rb +19 -0
  35. data/lib/generators/organizations/upgrade/templates/add_domain_verification.rb.erb +29 -0
  36. data/lib/generators/organizations/upgrade/templates/add_personal_organizations.rb.erb +22 -0
  37. data/lib/generators/organizations/upgrade/upgrade_generator.rb +79 -14
  38. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +1 -1
  39. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +2 -2
  40. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +1 -1
  41. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +21 -13
  42. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +1 -1
  43. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +3 -3
  44. data/lib/generators/organizations/views/views_generator.rb +4 -6
  45. data/lib/organizations/account_deletion_plan.rb +118 -0
  46. data/lib/organizations/configuration.rb +155 -2
  47. data/lib/organizations/controller_helpers.rb +55 -15
  48. data/lib/organizations/grant_access.rb +75 -0
  49. data/lib/organizations/grant_holder.rb +152 -0
  50. data/lib/organizations/grantable.rb +28 -0
  51. data/lib/organizations/join_flow.rb +16 -12
  52. data/lib/organizations/models/concerns/has_organizations.rb +217 -9
  53. data/lib/organizations/models/domain.rb +283 -1
  54. data/lib/organizations/models/grant.rb +152 -0
  55. data/lib/organizations/models/invitation.rb +79 -8
  56. data/lib/organizations/models/join_request.rb +102 -43
  57. data/lib/organizations/models/membership.rb +96 -15
  58. data/lib/organizations/models/organization.rb +219 -48
  59. data/lib/organizations/organization_scoped.rb +16 -0
  60. data/lib/organizations/personal_owner_backfill.rb +122 -0
  61. data/lib/organizations/public_email_domains.rb +49 -0
  62. data/lib/organizations/resource.rb +11 -0
  63. data/lib/organizations/roles.rb +198 -36
  64. data/lib/organizations/version.rb +1 -1
  65. data/lib/organizations/view_helpers.rb +40 -9
  66. data/lib/organizations.rb +99 -4
  67. 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
@@ -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
@@ -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>
@@ -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>