organizations 0.4.3 → 0.5.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -9
  3. data/.rubocop_todo.yml +625 -0
  4. data/CHANGELOG.md +46 -0
  5. data/README.md +405 -41
  6. data/app/controllers/organizations/application_controller.rb +4 -0
  7. data/app/controllers/organizations/memberships_controller.rb +3 -3
  8. data/app/controllers/organizations/public_controller.rb +26 -0
  9. data/app/mailers/organizations/invitation_mailer.rb +38 -18
  10. data/app/mailers/organizations/verification_mailer.rb +56 -0
  11. data/app/models/organizations/allowlist_entry.rb +6 -0
  12. data/app/models/organizations/domain.rb +6 -0
  13. data/app/models/organizations/join_code.rb +6 -0
  14. data/app/models/organizations/join_request.rb +6 -0
  15. data/app/views/organizations/invitation_mailer/invitation_email.html.erb +16 -13
  16. data/app/views/organizations/invitation_mailer/invitation_email.text.erb +8 -8
  17. data/app/views/organizations/verification_mailer/code_email.html.erb +22 -0
  18. data/app/views/organizations/verification_mailer/code_email.text.erb +7 -0
  19. data/config/locales/en.yml +158 -0
  20. data/config/locales/es.yml +126 -0
  21. data/config/routes.rb +46 -28
  22. data/gemfiles/rails_7.2.gemfile +3 -3
  23. data/gemfiles/rails_8.1.gemfile +3 -3
  24. data/lib/generators/organizations/install/install_generator.rb +3 -0
  25. data/lib/generators/organizations/install/templates/create_organizations_tables.rb.erb +170 -25
  26. data/lib/generators/organizations/install/templates/initializer.rb +53 -0
  27. data/lib/generators/organizations/upgrade/templates/add_verified_joining_to_organizations.rb.erb +180 -0
  28. data/lib/generators/organizations/upgrade/upgrade_generator.rb +50 -0
  29. data/lib/generators/organizations/views/templates/organizations/invitations/_form.html.erb +51 -0
  30. data/lib/generators/organizations/views/templates/organizations/invitations/index.html.erb +112 -0
  31. data/lib/generators/organizations/views/templates/organizations/invitations/new.html.erb +10 -0
  32. data/lib/generators/organizations/views/templates/organizations/invitations/show.html.erb +99 -0
  33. data/lib/generators/organizations/views/templates/organizations/memberships/index.html.erb +87 -0
  34. data/lib/generators/organizations/views/templates/organizations/organizations/_form.html.erb +39 -0
  35. data/lib/generators/organizations/views/templates/organizations/organizations/edit.html.erb +10 -0
  36. data/lib/generators/organizations/views/templates/organizations/organizations/index.html.erb +93 -0
  37. data/lib/generators/organizations/views/templates/organizations/organizations/new.html.erb +10 -0
  38. data/lib/generators/organizations/views/templates/organizations/organizations/show.html.erb +270 -0
  39. data/lib/generators/organizations/views/views_generator.rb +47 -0
  40. data/lib/organizations/callback_context.rb +9 -0
  41. data/lib/organizations/callbacks.rb +26 -18
  42. data/lib/organizations/configuration.rb +275 -1
  43. data/lib/organizations/controller_helpers.rb +38 -12
  44. data/lib/organizations/email_normalizer.rb +65 -0
  45. data/lib/organizations/join_flow.rb +227 -0
  46. data/lib/organizations/join_state.rb +117 -0
  47. data/lib/organizations/metadata_flags.rb +79 -0
  48. data/lib/organizations/models/allowlist_entry.rb +88 -0
  49. data/lib/organizations/models/concerns/has_organizations.rb +100 -12
  50. data/lib/organizations/models/domain.rb +85 -0
  51. data/lib/organizations/models/invitation.rb +105 -17
  52. data/lib/organizations/models/join_code.rb +242 -0
  53. data/lib/organizations/models/join_request.rb +627 -0
  54. data/lib/organizations/models/membership.rb +39 -8
  55. data/lib/organizations/models/organization.rb +317 -19
  56. data/lib/organizations/organization_scoped.rb +135 -0
  57. data/lib/organizations/test_helpers.rb +35 -1
  58. data/lib/organizations/version.rb +1 -1
  59. data/lib/organizations/view_helpers.rb +8 -12
  60. data/lib/organizations.rb +165 -0
  61. metadata +34 -2
@@ -28,6 +28,24 @@ module Organizations
28
28
  # Minimal helpers needed for public routes
29
29
  helper_method :current_user if respond_to?(:helper_method)
30
30
 
31
+ # Host helper decoration (config.public_controller_helpers): this
32
+ # controller deliberately does NOT inherit the host ApplicationController
33
+ # (to dodge auth filters), which also means host layouts rendered by
34
+ # public pages (invitation acceptance in the app's devise/marketing
35
+ # layout) are missing the app's helper modules. Both known production
36
+ # hosts hand-patched exactly this in an initializer — declare it instead:
37
+ #
38
+ # config.public_controller_helpers = ["ApplicationHelper", "PageHelper"]
39
+ #
40
+ # Strings are constantized HERE, at controller-class load time (after
41
+ # initializers, reload-safe), so the initializer never has to reference
42
+ # autoloadable constants directly.
43
+ if respond_to?(:helper)
44
+ Organizations.configuration.public_controller_helpers.each do |helper_module|
45
+ helper(helper_module.is_a?(Module) ? helper_module : helper_module.to_s.constantize)
46
+ end
47
+ end
48
+
31
49
  private
32
50
 
33
51
  # Returns the current user from the host application (if any).
@@ -59,3 +77,11 @@ module Organizations
59
77
  helper_method :main_app if respond_to?(:helper_method)
60
78
  end
61
79
  end
80
+
81
+ # Host extension seam — see the load-hooks note in
82
+ # lib/organizations/models/organization.rb. Typical use: decorating this
83
+ # controller with host helper modules its layout needs (it inherits
84
+ # ActionController::Base, NOT the host ApplicationController, so app helpers
85
+ # aren't present by default). Prefer config.public_controller_helpers for
86
+ # that common case; this hook is for arbitrary decoration.
87
+ ActiveSupport.run_load_hooks(:organizations_public_controller, Organizations::PublicController)
@@ -11,6 +11,13 @@ module Organizations
11
11
  # InvitationMailer.invitation_email(invitation).deliver_later
12
12
  #
13
13
  class InvitationMailer < ActionMailer::Base
14
+ # Self-register the gem's app/views so this mailer renders OUTSIDE a full
15
+ # Rails app too (plain-ActiveRecord consumers, this gem's own test env).
16
+ # Under Rails the engine already provides the path — appending a
17
+ # duplicate is harmless (lowest precedence), and host template overrides
18
+ # in the app's own app/views still win.
19
+ append_view_path File.expand_path("../../views", __dir__)
20
+
14
21
  default from: -> { default_from_address }
15
22
 
16
23
  # Invitation email
@@ -20,18 +27,23 @@ module Organizations
20
27
  @invitation = invitation
21
28
  @organization = invitation.organization
22
29
  @inviter = invitation.invited_by
30
+ @inviter_name = inviter_name
23
31
  @accept_url = invitation_accept_url(invitation)
32
+ # Pre-formatted in the mailer (not the templates) so the strftime
33
+ # fallback lives in exactly one place — see #format_expiry.
34
+ @expires_on = @invitation.expires_at ? format_expiry(@invitation.expires_at) : nil
24
35
 
25
36
  mail(
26
37
  to: invitation.email,
27
- subject: "#{inviter_name} invited you to join #{@organization.name}"
38
+ subject: Organizations.t(:"mailers.invitation.subject",
39
+ inviter: @inviter_name, organization: @organization.name)
28
40
  )
29
41
  end
30
42
 
31
43
  private
32
44
 
33
45
  def inviter_name
34
- return "The team" unless @inviter
46
+ return Organizations.t(:"mailers.from_team") unless @inviter
35
47
 
36
48
  if @inviter.respond_to?(:name) && @inviter.name.present?
37
49
  @inviter.name
@@ -40,25 +52,33 @@ module Organizations
40
52
  end
41
53
  end
42
54
 
55
+ # Localize the expiry timestamp when the host has date/time translations
56
+ # (rails-i18n or its own time.formats.long); otherwise fall back to the
57
+ # historical English strftime. Bare i18n (this gem's own test env, plain
58
+ # Ruby consumers) has no time formats, and I18n.l raises in that case —
59
+ # never let a missing date format break invitation delivery.
60
+ def format_expiry(time)
61
+ I18n.l(time, format: :long)
62
+ rescue I18n::MissingTranslationData, I18n::ArgumentError
63
+ time.strftime("%B %d, %Y at %I:%M %p %Z")
64
+ end
65
+
43
66
  def invitation_accept_url(invitation)
44
- if defined?(Rails) && Rails.application&.routes
45
- # Try to use the engine routes
46
- begin
47
- Organizations::Engine.routes.url_helpers.invitation_url(
48
- invitation.token,
49
- host: default_host
50
- )
51
- rescue StandardError
52
- # Fallback to basic URL construction
53
- "#{default_host}/invitations/#{invitation.token}"
54
- end
55
- else
56
- "/invitations/#{invitation.token}"
57
- end
67
+ # ONE implementation for acceptance URLs: Invitation#acceptance_url
68
+ # (mount-point aware via Organizations.engine_mount_path). The previous
69
+ # engine-url_helpers attempt here was mount-UNAWARE — raw engine route
70
+ # helpers don't know where the host mounted the engine, so links broke
71
+ # for any non-root mount.
72
+ invitation.acceptance_url(base_url: full_rails_app? ? default_host : "")
73
+ end
74
+
75
+ # SSOT for the bare-`Rails`-module guard — see Organizations.full_rails_app?.
76
+ def full_rails_app?
77
+ Organizations.full_rails_app?
58
78
  end
59
79
 
60
80
  def default_from_address
61
- if defined?(Rails) && Rails.application&.config&.action_mailer&.default_options
81
+ if full_rails_app? && Rails.application.config.action_mailer&.default_options
62
82
  Rails.application.config.action_mailer.default_options[:from] || "noreply@example.com"
63
83
  else
64
84
  "noreply@example.com"
@@ -66,7 +86,7 @@ module Organizations
66
86
  end
67
87
 
68
88
  def default_host
69
- if defined?(Rails) && Rails.application&.config&.action_mailer&.default_url_options
89
+ if full_rails_app? && Rails.application.config.action_mailer&.default_url_options
70
90
  options = Rails.application.config.action_mailer.default_url_options
71
91
  protocol = options[:protocol] || "https"
72
92
  host = options[:host] || "localhost"
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # Mailer for emailed verification codes (verified joining).
5
+ # Can be customized via Organizations.configuration.verification_mailer —
6
+ # custom mailers must implement `code_email(join_request, code)`.
7
+ #
8
+ # SECURITY: `code` is the plaintext one-time code. It exists only in this
9
+ # delivery path — the database stores a digest (see JoinRequest).
10
+ #
11
+ # @example
12
+ # VerificationMailer.code_email(join_request, "492817").deliver_later
13
+ #
14
+ class VerificationMailer < ActionMailer::Base
15
+ # Self-register the gem's app/views — see InvitationMailer for rationale.
16
+ append_view_path File.expand_path("../../views", __dir__)
17
+
18
+ default from: -> { default_from_address }
19
+
20
+ # Verification code email
21
+ # @param join_request [Organizations::JoinRequest]
22
+ # @param code [String] the plaintext 6-digit code
23
+ # @return [Mail::Message]
24
+ def code_email(join_request, code)
25
+ @join_request = join_request
26
+ @organization = join_request.organization
27
+ @code = code
28
+ @expires_in_minutes = ttl_minutes
29
+
30
+ mail(
31
+ to: join_request.verification_email,
32
+ subject: Organizations.t(:"mailers.verification.subject",
33
+ code: @code, organization: @organization.name)
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ def ttl_minutes
40
+ ttl = Organizations.configuration.verification_code_ttl
41
+ (ttl.to_i / 60.0).ceil
42
+ end
43
+
44
+ def default_from_address
45
+ # Deliberately identical to InvitationMailer#default_from_address —
46
+ # keep the two in sync. The bare-`Rails`-module guard lives in ONE
47
+ # place: Organizations.full_rails_app?.
48
+ if Organizations.full_rails_app? &&
49
+ Rails.application.config.action_mailer&.default_options
50
+ Rails.application.config.action_mailer.default_options[:from] || "noreply@example.com"
51
+ else
52
+ "noreply@example.com"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reloadable entrypoint for Organizations::AllowlistEntry in Rails apps.
4
+ # This file lives in app/models so Zeitwerk manages it, making the class
5
+ # reload-safe. It delegates to the canonical implementation in lib/.
6
+ load File.expand_path("../../../lib/organizations/models/allowlist_entry.rb", __dir__)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reloadable entrypoint for Organizations::Domain in Rails apps.
4
+ # This file lives in app/models so Zeitwerk manages it, making the class
5
+ # reload-safe. It delegates to the canonical implementation in lib/.
6
+ load File.expand_path("../../../lib/organizations/models/domain.rb", __dir__)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reloadable entrypoint for Organizations::JoinCode in Rails apps.
4
+ # This file lives in app/models so Zeitwerk manages it, making the class
5
+ # reload-safe. It delegates to the canonical implementation in lib/.
6
+ load File.expand_path("../../../lib/organizations/models/join_code.rb", __dir__)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reloadable entrypoint for Organizations::JoinRequest in Rails apps.
4
+ # This file lives in app/models so Zeitwerk manages it, making the class
5
+ # reload-safe. It delegates to the canonical implementation in lib/.
6
+ load File.expand_path("../../../lib/organizations/models/join_request.rb", __dir__)
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>You're invited to join <%= @organization.name %></title>
6
+ <title><%= Organizations.t(:"mailers.invitation.title", organization: @organization.name) %></title>
7
7
  <style>
8
8
  body {
9
9
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
@@ -53,45 +53,48 @@
53
53
  </head>
54
54
  <body>
55
55
  <div class="header">
56
- <h1>You're invited!</h1>
56
+ <h1><%= Organizations.t(:"mailers.invitation.header") %></h1>
57
57
  </div>
58
58
 
59
59
  <div class="content">
60
- <p>Hi there,</p>
60
+ <p><%= Organizations.t(:"mailers.invitation.greeting") %></p>
61
61
 
62
62
  <p>
63
+ <%# The *_html keys go through the view `t` helper: its _html-suffix
64
+ convention marks the translation html_safe while HTML-escaping every
65
+ interpolated value (inviter/org names are user input). Source:
66
+ https://guides.rubyonrails.org/i18n.html#using-safe-html-translations %>
63
67
  <% if @inviter %>
64
- <strong><%= @inviter.respond_to?(:name) && @inviter.name.present? ? @inviter.name : @inviter.email %></strong>
65
- has invited you to join <strong><%= @organization.name %></strong>.
68
+ <%= t("organizations.mailers.invitation.invited_by_html", inviter: @inviter_name, organization: @organization.name) %>
66
69
  <% else %>
67
- You've been invited to join <strong><%= @organization.name %></strong>.
70
+ <%= t("organizations.mailers.invitation.invited_generic_html", organization: @organization.name) %>
68
71
  <% end %>
69
72
  </p>
70
73
 
71
74
  <p>
72
- You'll be joining as:
73
- <span class="role-badge"><%= @invitation.role.to_s.capitalize %></span>
75
+ <%= Organizations.t(:"mailers.invitation.joining_as_label") %>
76
+ <span class="role-badge"><%= Organizations.t(:"roles.#{@invitation.role}", default: @invitation.role.to_s.capitalize) %></span>
74
77
  </p>
75
78
 
76
79
  <p style="text-align: center;">
77
- <a href="<%= @accept_url %>" class="button">Accept Invitation</a>
80
+ <a href="<%= @accept_url %>" class="button"><%= Organizations.t(:"mailers.invitation.accept_button") %></a>
78
81
  </p>
79
82
 
80
83
  <p>
81
- Or copy and paste this link into your browser:<br>
84
+ <%= Organizations.t(:"mailers.invitation.copy_link") %><br>
82
85
  <a href="<%= @accept_url %>"><%= @accept_url %></a>
83
86
  </p>
84
87
 
85
- <% if @invitation.expires_at %>
88
+ <% if @expires_on %>
86
89
  <p style="color: #666; font-size: 14px;">
87
- This invitation will expire on <%= @invitation.expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>.
90
+ <%= Organizations.t(:"mailers.invitation.expires_on", date: @expires_on) %>
88
91
  </p>
89
92
  <% end %>
90
93
  </div>
91
94
 
92
95
  <div class="footer">
93
96
  <p>
94
- If you weren't expecting this invitation, you can safely ignore this email.
97
+ <%= Organizations.t(:"mailers.invitation.ignore") %>
95
98
  </p>
96
99
  </div>
97
100
  </body>
@@ -1,18 +1,18 @@
1
- You're invited to join <%= @organization.name %>!
1
+ <%= Organizations.t(:"mailers.invitation.title", organization: @organization.name) %>
2
2
 
3
- Hi there,
3
+ <%= Organizations.t(:"mailers.invitation.greeting") %>
4
4
 
5
- <% if @inviter %><%= @inviter.respond_to?(:name) && @inviter.name.present? ? @inviter.name : @inviter.email %> has invited you to join <%= @organization.name %>.<% else %>You've been invited to join <%= @organization.name %>.<% end %>
5
+ <% if @inviter %><%= Organizations.t(:"mailers.invitation.invited_by", inviter: @inviter_name, organization: @organization.name) %><% else %><%= Organizations.t(:"mailers.invitation.invited_generic", organization: @organization.name) %><% end %>
6
6
 
7
- You'll be joining as: <%= @invitation.role.to_s.capitalize %>
7
+ <%= Organizations.t(:"mailers.invitation.joining_as", role: Organizations.t(:"roles.#{@invitation.role}", default: @invitation.role.to_s.capitalize)) %>
8
8
 
9
- Accept your invitation here:
9
+ <%= Organizations.t(:"mailers.invitation.accept_cta") %>
10
10
  <%= @accept_url %>
11
11
 
12
- <% if @invitation.expires_at %>
13
- This invitation will expire on <%= @invitation.expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>.
12
+ <% if @expires_on %>
13
+ <%= Organizations.t(:"mailers.invitation.expires_on", date: @expires_on) %>
14
14
  <% end %>
15
15
 
16
16
  ---
17
17
 
18
- If you weren't expecting this invitation, you can safely ignore this email.
18
+ <%= Organizations.t(:"mailers.invitation.ignore") %>
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
5
+ </head>
6
+ <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #111827; margin: 0; padding: 24px;">
7
+ <%# _html key via the view `t` helper: html_safe translation, escaped
8
+ interpolations (the org name is user input). Source:
9
+ https://guides.rubyonrails.org/i18n.html#using-safe-html-translations %>
10
+ <p><%= t("organizations.mailers.verification.lead_html", organization: @organization.name) %></p>
11
+
12
+ <p style="font-size: 32px; font-weight: 700; letter-spacing: 6px; background: #f3f4f6; border-radius: 8px; display: inline-block; padding: 12px 24px;">
13
+ <%= @code %>
14
+ </p>
15
+
16
+ <p><%= Organizations.t(:"mailers.verification.expires", minutes: @expires_in_minutes) %></p>
17
+
18
+ <p style="color: #6b7280; font-size: 14px;">
19
+ <%= Organizations.t(:"mailers.verification.ignore", organization: @organization.name) %>
20
+ </p>
21
+ </body>
22
+ </html>
@@ -0,0 +1,7 @@
1
+ <%= Organizations.t(:"mailers.verification.lead", organization: @organization.name) %>
2
+
3
+ <%= @code %>
4
+
5
+ <%= Organizations.t(:"mailers.verification.expires", minutes: @expires_in_minutes) %>
6
+
7
+ <%= Organizations.t(:"mailers.verification.ignore", organization: @organization.name) %>
@@ -0,0 +1,158 @@
1
+ # Organizations gem — English (default) locale.
2
+ #
3
+ # This file is the SINGLE SOURCE OF TRUTH for every user-facing string the
4
+ # gem produces: error messages raised by the models, role/status labels used
5
+ # by the view helpers, model validation messages, and the stock mailer copy.
6
+ #
7
+ # Hosts override any of these the standard Rails way: define the same keys in
8
+ # the app's own locale files (app files load after engine files, so they win).
9
+ # See README "Internationalization (i18n)".
10
+ #
11
+ # ⚠️ Developer-facing errors (ArgumentError, ConfigurationError, wrong-API
12
+ # usage like "user is required") are deliberately NOT translated — they are
13
+ # for programmers, and English keeps them greppable/searchable.
14
+ #
15
+ # ⚠️ Keys under `errors:` are 1:1 with raise sites in lib/organizations/**.
16
+ # If you add a raise site, add its key HERE (and to es.yml) — a missing key
17
+ # renders as "Translation missing: ..." which is a loud, findable bug.
18
+ en:
19
+ organizations:
20
+ # Role labels (used by organization_role_label / organization_role_info)
21
+ roles:
22
+ owner: "Owner"
23
+ admin: "Admin"
24
+ member: "Member"
25
+ viewer: "Viewer"
26
+
27
+ # Invitation status labels (organization_invitation_status_label)
28
+ invitation_status:
29
+ pending: "Pending"
30
+ accepted: "Accepted"
31
+ expired: "Expired"
32
+
33
+ # Join request statuses — lowercase on purpose: interpolated mid-sentence
34
+ # into errors.join_request_already_decided ("...has already been %{status}").
35
+ join_request_status:
36
+ pending: "pending"
37
+ approved: "approved"
38
+ rejected: "rejected"
39
+ withdrawn: "withdrawn"
40
+ expired: "expired"
41
+
42
+ errors:
43
+ # — Join codes —
44
+ join_code_invalid: "This code is not valid"
45
+ join_code_exhausted: "This code has reached its usage limit"
46
+
47
+ # — Join requests —
48
+ join_request_expired: "This join request has expired"
49
+ join_request_already_decided: "This join request has already been %{status}"
50
+ join_request_already_member: "You are already a member of this organization"
51
+ join_request_create_failed: "Could not create join request"
52
+ join_request_membership_gone: "Request was approved but the membership no longer exists"
53
+
54
+ # — Membership veto gate (on_member_joining raising MembershipVetoed) —
55
+ # Default shown when the host's veto raises without its own message.
56
+ membership_vetoed: "You can't join this organization right now"
57
+
58
+ # — Email verification challenge —
59
+ verification_email_invalid: "This is not a valid email address"
60
+ verification_email_not_eligible: "This email address is not eligible to join this organization"
61
+ verification_email_already_claimed: "This email address is already associated with a member of this organization"
62
+ verification_code_invalid: "The code is incorrect"
63
+ verification_code_missing: "No verification code is active for this request"
64
+ verification_code_expired: "This code has expired — request a new one"
65
+ verification_attempts_exceeded: "Too many incorrect attempts — request a new code"
66
+ verification_sends_exceeded: "Too many codes requested for this request"
67
+ verification_resend_throttled: "Please wait before requesting another code"
68
+ account_email_trust_disabled: "Account-email trust is disabled (see config.trust_confirmed_account_email)"
69
+ account_email_unconfirmed: "The account email has not been confirmed"
70
+ not_accepting_requests: "This organization is not accepting join requests right now"
71
+
72
+ # — Invitations —
73
+ invitation_expired: "This invitation has expired"
74
+ invitation_already_accepted: "This invitation has already been accepted"
75
+ invitation_cannot_resend_accepted: "Cannot resend an accepted invitation"
76
+ invitation_email_mismatch: "This invitation was sent to a different email address"
77
+ invitation_accept_as_owner: "Cannot accept invitation as owner. Invite as admin, then use transfer_ownership_to! after joining."
78
+ invitation_already_member: "User is already a member of this organization"
79
+ invite_as_owner: "Cannot invite as owner. Invite as admin, then use transfer_ownership_to! after they join."
80
+ invite_not_a_member: "Only organization members can send invitations"
81
+ invite_not_authorized: "You don't have permission to invite members"
82
+
83
+ # — Ownership / role invariants (reachable from admin UIs) —
84
+ cannot_remove_owner: "Cannot remove the organization owner. Transfer ownership first."
85
+ cannot_add_as_owner: "Cannot add member as owner. Use transfer_ownership_to! instead."
86
+ cannot_promote_to_owner: "Cannot promote to owner. Use transfer_ownership_to! instead."
87
+ cannot_demote_owner_directly: "Cannot demote owner directly. Use transfer_ownership_to! instead."
88
+ cannot_demote_owner: "Cannot demote owner. Transfer ownership first."
89
+ promote_not_higher: "Cannot promote to %{new_role} - it's not a higher role than %{role}"
90
+ demote_not_lower: "Cannot demote to %{new_role} - it's not a lower role than %{role}"
91
+ transfer_no_owner: "Cannot transfer ownership because organization has no owner membership"
92
+ transfer_to_non_member: "Cannot transfer ownership to a non-member"
93
+ transfer_to_non_admin: "Cannot transfer ownership to non-admin. Promote them to admin first."
94
+
95
+ # — Leaving / user-level —
96
+ cannot_leave_as_last_owner: "Cannot leave organization as the only owner. Transfer ownership first."
97
+ cannot_leave_last_organization: "Cannot leave your only organization"
98
+ organization_limit_reached: "Maximum number of organizations (%{max}) reached"
99
+ no_current_organization_to_leave: "No current organization to leave"
100
+ cannot_delete_user_owns_organizations: "Cannot delete a user who still owns organizations. Transfer ownership or delete those organizations first."
101
+
102
+ # — Controller guards (default unauthorized copy) —
103
+ unauthorized_role: "You need %{role} access to perform this action"
104
+ unauthorized_permission: "You don't have permission to %{permission}"
105
+ unauthorized: "You are not authorized to perform this action"
106
+ not_a_member: "You are not a member of this organization"
107
+ organization_required: "Organization required"
108
+ cannot_change_own_role: "You cannot change your own role"
109
+ invalid_role: "Invalid role: %{role}"
110
+
111
+ # Flash notices produced by the controller-helper invitation flow.
112
+ notices:
113
+ welcome: "Welcome to %{organization}!"
114
+ already_member: "You're already a member of %{organization}."
115
+ select_or_create_organization: "Please select or create an organization."
116
+
117
+ # Model validation messages (errors.add second argument). These read as
118
+ # "<Attribute> <message>" in full_messages — keep them lowercase fragments.
119
+ attributes:
120
+ membership_taken: "is already a member of this organization"
121
+ owner_taken: "owner already exists for this organization"
122
+ pending_request_taken: "already has a pending request for this organization"
123
+ invitation_taken: "has already been invited to this organization"
124
+ allowlist_taken: "is already on this organization's allowlist"
125
+ domain_invalid: "is not a valid domain name"
126
+
127
+ # Stock mailer copy. Hosts that swap in their own mailer
128
+ # (config.invitation_mailer / config.verification_mailer) ignore all of
129
+ # this; hosts that keep the stock mailers can retheme by overriding keys.
130
+ mailers:
131
+ from_team: "The team"
132
+ invitation:
133
+ subject: "%{inviter} invited you to join %{organization}"
134
+ header: "You're invited!"
135
+ title: "You're invited to join %{organization}!"
136
+ greeting: "Hi there,"
137
+ invited_by: "%{inviter} has invited you to join %{organization}."
138
+ # _html variants: rendered through the view `t` helper, whose *_html
139
+ # convention marks the string html_safe and HTML-escapes every
140
+ # interpolated argument (Rails i18n guide §4.1.2 "HTML translations",
141
+ # https://guides.rubyonrails.org/i18n.html) — keep markup here, never
142
+ # in the interpolated values.
143
+ invited_by_html: "<strong>%{inviter}</strong> has invited you to join <strong>%{organization}</strong>."
144
+ invited_generic: "You've been invited to join %{organization}."
145
+ invited_generic_html: "You've been invited to join <strong>%{organization}</strong>."
146
+ joining_as: "You'll be joining as: %{role}"
147
+ joining_as_label: "You'll be joining as:"
148
+ accept_cta: "Accept your invitation here:"
149
+ accept_button: "Accept Invitation"
150
+ copy_link: "Or copy and paste this link into your browser:"
151
+ expires_on: "This invitation will expire on %{date}."
152
+ ignore: "If you weren't expecting this invitation, you can safely ignore this email."
153
+ verification:
154
+ subject: "%{code} is your %{organization} verification code"
155
+ lead: "Use this code to verify your email address and join %{organization}:"
156
+ lead_html: "Use this code to verify your email address and join <strong>%{organization}</strong>:"
157
+ expires: "The code expires in %{minutes} minutes."
158
+ ignore: "If you didn't request this code, you can safely ignore this email — nobody can join %{organization} with your address without it."
@@ -0,0 +1,126 @@
1
+ # Organizations gem — Spanish locale.
2
+ #
3
+ # Ships with the gem so Spanish-market hosts get correct end-user copy out of
4
+ # the box (the first two production hosts of this gem are Spanish products).
5
+ # Keep key parity with en.yml — en.yml is the catalog SSOT; this file must
6
+ # always answer the same keys. Tone: neutral "tú", consistent with typical
7
+ # Spanish SaaS copy.
8
+ es:
9
+ organizations:
10
+ roles:
11
+ owner: "Propietario"
12
+ admin: "Administrador"
13
+ member: "Miembro"
14
+ viewer: "Observador"
15
+
16
+ invitation_status:
17
+ pending: "Pendiente"
18
+ accepted: "Aceptada"
19
+ expired: "Caducada"
20
+
21
+ # En minúsculas a propósito: se interpolan a mitad de frase en
22
+ # errors.join_request_already_decided.
23
+ join_request_status:
24
+ pending: "pendiente"
25
+ approved: "aprobada"
26
+ rejected: "rechazada"
27
+ withdrawn: "retirada"
28
+ expired: "caducada"
29
+
30
+ errors:
31
+ join_code_invalid: "Este código no es válido"
32
+ join_code_exhausted: "Este código ya ha alcanzado su límite de usos"
33
+
34
+ join_request_expired: "Esta solicitud ha caducado"
35
+ join_request_already_decided: "Esta solicitud ya está %{status}"
36
+ join_request_already_member: "Ya eres miembro de esta organización"
37
+ join_request_create_failed: "No se ha podido crear la solicitud"
38
+ join_request_membership_gone: "La solicitud se aprobó pero la membresía ya no existe"
39
+
40
+ membership_vetoed: "Ahora mismo no puedes unirte a esta organización"
41
+
42
+ verification_email_invalid: "Esta dirección de email no es válida"
43
+ verification_email_not_eligible: "Ese email no pertenece a esta organización"
44
+ verification_email_already_claimed: "Ese email ya está asociado a un miembro de esta organización"
45
+ verification_code_invalid: "El código no es correcto"
46
+ verification_code_missing: "No hay ningún código de verificación activo para esta solicitud"
47
+ verification_code_expired: "El código ha caducado — pide uno nuevo"
48
+ verification_attempts_exceeded: "Demasiados intentos — pide un código nuevo"
49
+ verification_sends_exceeded: "Se han pedido demasiados códigos para esta solicitud"
50
+ verification_resend_throttled: "Espera un momento antes de pedir otro código"
51
+ account_email_trust_disabled: "La verificación por email de cuenta está desactivada (config.trust_confirmed_account_email)"
52
+ account_email_unconfirmed: "El email de la cuenta no está confirmado"
53
+ not_accepting_requests: "Esta organización no acepta solicitudes ahora mismo"
54
+
55
+ invitation_expired: "Esta invitación ha caducado"
56
+ invitation_already_accepted: "Esta invitación ya ha sido aceptada"
57
+ invitation_cannot_resend_accepted: "No se puede reenviar una invitación ya aceptada"
58
+ invitation_email_mismatch: "Esta invitación se envió a otra dirección de email"
59
+ invitation_accept_as_owner: "No se puede aceptar una invitación como propietario. Invita como administrador y transfiere la propiedad después."
60
+ invitation_already_member: "Esa persona ya es miembro de esta organización"
61
+ invite_as_owner: "No se puede invitar como propietario. Invita como administrador y transfiere la propiedad después."
62
+ invite_not_a_member: "Solo los miembros de la organización pueden enviar invitaciones"
63
+ invite_not_authorized: "No tienes permiso para invitar miembros"
64
+
65
+ cannot_remove_owner: "No se puede eliminar al propietario de la organización. Transfiere la propiedad primero."
66
+ cannot_add_as_owner: "No se puede añadir un miembro como propietario. Usa la transferencia de propiedad."
67
+ cannot_promote_to_owner: "No se puede ascender a propietario. Usa la transferencia de propiedad."
68
+ cannot_demote_owner_directly: "No se puede degradar al propietario directamente. Usa la transferencia de propiedad."
69
+ cannot_demote_owner: "No se puede degradar al propietario. Transfiere la propiedad primero."
70
+ promote_not_higher: "No se puede ascender a %{new_role}: no es un rol superior a %{role}"
71
+ demote_not_lower: "No se puede degradar a %{new_role}: no es un rol inferior a %{role}"
72
+ transfer_no_owner: "No se puede transferir la propiedad porque la organización no tiene propietario"
73
+ transfer_to_non_member: "No se puede transferir la propiedad a alguien que no es miembro"
74
+ transfer_to_non_admin: "No se puede transferir la propiedad a alguien que no es administrador. Asciéndele primero."
75
+
76
+ cannot_leave_as_last_owner: "No puedes salir de la organización siendo su único propietario. Transfiere la propiedad primero."
77
+ cannot_leave_last_organization: "No puedes salir de tu única organización"
78
+ organization_limit_reached: "Has alcanzado el máximo de organizaciones (%{max})"
79
+ no_current_organization_to_leave: "No hay ninguna organización activa de la que salir"
80
+ cannot_delete_user_owns_organizations: "No se puede eliminar un usuario que aún es propietario de organizaciones. Transfiere la propiedad o elimina esas organizaciones primero."
81
+
82
+ unauthorized_role: "Necesitas acceso de %{role} para hacer esto"
83
+ unauthorized_permission: "No tienes permiso para %{permission}"
84
+ unauthorized: "No estás autorizado para hacer esto"
85
+ not_a_member: "No eres miembro de esta organización"
86
+ organization_required: "Se requiere una organización"
87
+ cannot_change_own_role: "No puedes cambiar tu propio rol"
88
+ invalid_role: "Rol no válido: %{role}"
89
+
90
+ notices:
91
+ welcome: "¡Ya formas parte de %{organization}!"
92
+ already_member: "Ya eres miembro de %{organization}."
93
+ select_or_create_organization: "Selecciona o crea una organización."
94
+
95
+ attributes:
96
+ membership_taken: "ya es miembro de esta organización"
97
+ owner_taken: "ya existe un propietario para esta organización"
98
+ pending_request_taken: "ya tiene una solicitud pendiente para esta organización"
99
+ invitation_taken: "ya ha sido invitado a esta organización"
100
+ allowlist_taken: "ya está en la lista de esta organización"
101
+ domain_invalid: "no es un nombre de dominio válido"
102
+
103
+ mailers:
104
+ from_team: "El equipo"
105
+ invitation:
106
+ subject: "%{inviter} te ha invitado a unirte a %{organization}"
107
+ header: "¡Te han invitado!"
108
+ title: "¡Te han invitado a unirte a %{organization}!"
109
+ greeting: "Hola,"
110
+ invited_by: "%{inviter} te ha invitado a unirte a %{organization}."
111
+ invited_by_html: "<strong>%{inviter}</strong> te ha invitado a unirte a <strong>%{organization}</strong>."
112
+ invited_generic: "Te han invitado a unirte a %{organization}."
113
+ invited_generic_html: "Te han invitado a unirte a <strong>%{organization}</strong>."
114
+ joining_as: "Te unirás como: %{role}"
115
+ joining_as_label: "Te unirás como:"
116
+ accept_cta: "Acepta tu invitación aquí:"
117
+ accept_button: "Aceptar invitación"
118
+ copy_link: "O copia y pega este enlace en tu navegador:"
119
+ expires_on: "Esta invitación caducará el %{date}."
120
+ ignore: "Si no esperabas esta invitación, puedes ignorar este email."
121
+ verification:
122
+ subject: "%{code} es tu código de verificación de %{organization}"
123
+ lead: "Usa este código para verificar tu email y unirte a %{organization}:"
124
+ lead_html: "Usa este código para verificar tu email y unirte a <strong>%{organization}</strong>:"
125
+ expires: "El código caduca en %{minutes} minutos."
126
+ ignore: "Si no has pedido este código, puedes ignorar este email — nadie puede unirse a %{organization} con tu dirección sin él."