plutonium 0.39.1 → 0.40.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-connect-resource/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-controller/SKILL.md +5 -9
  4. data/.claude/skills/plutonium-definition-query/SKILL.md +10 -2
  5. data/.claude/skills/plutonium-installation/SKILL.md +9 -7
  6. data/.claude/skills/plutonium-invites/SKILL.md +363 -0
  7. data/.claude/skills/plutonium-package/SKILL.md +2 -1
  8. data/.claude/skills/plutonium-portal/SKILL.md +30 -16
  9. data/.claude/skills/plutonium-rodauth/SKILL.md +111 -18
  10. data/CHANGELOG.md +48 -0
  11. data/app/assets/plutonium.css +1 -1
  12. data/config/initializers/sqlite_alias.rb +8 -8
  13. data/docs/.vitepress/config.ts +1 -0
  14. data/docs/getting-started/tutorial/07-author-portal.md +1 -0
  15. data/docs/getting-started/tutorial/08-customizing-ui.md +5 -2
  16. data/docs/guides/adding-resources.md +10 -0
  17. data/docs/guides/authentication.md +15 -8
  18. data/docs/guides/creating-packages.md +13 -8
  19. data/docs/guides/index.md +2 -0
  20. data/docs/guides/search-filtering.md +8 -3
  21. data/docs/guides/user-invites.md +497 -0
  22. data/docs/public/templates/base.rb +5 -1
  23. data/docs/public/templates/lite.rb +42 -0
  24. data/docs/public/templates/pluton8.rb +7 -2
  25. data/docs/reference/controller/index.md +12 -7
  26. data/docs/reference/definition/query.md +12 -3
  27. data/docs/reference/generators/index.md +70 -10
  28. data/docs/reference/portal/index.md +22 -11
  29. data/gemfiles/rails_7.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  31. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  32. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +31 -0
  33. data/lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt +58 -0
  34. data/lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake +6 -1
  35. data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -0
  36. data/lib/generators/pu/invites/USAGE +27 -0
  37. data/lib/generators/pu/invites/install_generator.rb +364 -0
  38. data/lib/generators/pu/invites/invitable/USAGE +31 -0
  39. data/lib/generators/pu/invites/invitable_generator.rb +143 -0
  40. data/lib/generators/pu/invites/templates/INSTRUCTIONS +22 -0
  41. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +24 -0
  42. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +26 -0
  43. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +47 -0
  44. data/lib/generators/pu/invites/templates/invitable/invitation.html.erb.tt +45 -0
  45. data/lib/generators/pu/invites/templates/invitable/invitation.text.erb.tt +15 -0
  46. data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +33 -0
  47. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +77 -0
  48. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +68 -0
  49. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +23 -0
  50. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +7 -0
  51. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/resend_invite_interaction.rb.tt +7 -0
  52. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +34 -0
  53. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +41 -0
  54. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +33 -0
  55. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/error.html.erb.tt +24 -0
  56. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +40 -0
  57. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +39 -0
  58. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +49 -0
  59. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +45 -0
  60. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +15 -0
  61. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +23 -0
  62. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +33 -0
  63. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +23 -2
  64. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +130 -0
  65. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +72 -0
  66. data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -2
  67. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +7 -1
  68. data/lib/generators/pu/lite/litestream/litestream_generator.rb +105 -0
  69. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +88 -0
  70. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +14 -0
  71. data/lib/generators/pu/lite/setup/setup_generator.rb +54 -0
  72. data/lib/generators/pu/lite/solid_cable/solid_cable_generator.rb +65 -0
  73. data/lib/generators/pu/lite/solid_cache/solid_cache_generator.rb +66 -0
  74. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +61 -0
  75. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +107 -0
  76. data/lib/generators/pu/pkg/portal/USAGE +8 -2
  77. data/lib/generators/pu/pkg/portal/portal_generator.rb +11 -1
  78. data/lib/generators/pu/pkg/portal/templates/app/controllers/concerns/controller.rb.tt +2 -0
  79. data/lib/generators/pu/pkg/portal/templates/app/controllers/plutonium_controller.rb.tt +1 -0
  80. data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +7 -0
  81. data/lib/generators/pu/pkg/portal/templates/lib/engine.rb.tt +3 -0
  82. data/lib/generators/pu/res/conn/USAGE +5 -0
  83. data/lib/generators/pu/res/conn/conn_generator.rb +30 -4
  84. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +6 -3
  85. data/lib/generators/pu/res/scaffold/templates/policy.rb.tt +6 -6
  86. data/lib/generators/pu/rodauth/account_generator.rb +36 -11
  87. data/lib/generators/pu/rodauth/admin_generator.rb +55 -0
  88. data/lib/generators/pu/rodauth/install_generator.rb +1 -8
  89. data/lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt +25 -0
  90. data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +6 -2
  91. data/lib/generators/pu/saas/USAGE +22 -0
  92. data/lib/generators/pu/saas/entity/USAGE +19 -0
  93. data/lib/generators/pu/saas/entity_generator.rb +55 -0
  94. data/lib/generators/pu/saas/membership/USAGE +25 -0
  95. data/lib/generators/pu/saas/membership_generator.rb +165 -0
  96. data/lib/generators/pu/saas/setup/USAGE +27 -0
  97. data/lib/generators/pu/saas/setup_generator.rb +98 -0
  98. data/lib/generators/pu/saas/user/USAGE +21 -0
  99. data/lib/generators/pu/saas/user_generator.rb +66 -0
  100. data/lib/plutonium/core/controller.rb +9 -5
  101. data/lib/plutonium/definition/base.rb +3 -1
  102. data/lib/plutonium/definition/scoping.rb +20 -0
  103. data/lib/plutonium/invites/concerns/cancel_invite.rb +44 -0
  104. data/lib/plutonium/invites/concerns/invitable.rb +98 -0
  105. data/lib/plutonium/invites/concerns/invite_token.rb +186 -0
  106. data/lib/plutonium/invites/concerns/invite_user.rb +147 -0
  107. data/lib/plutonium/invites/concerns/resend_invite.rb +66 -0
  108. data/lib/plutonium/invites/controller.rb +226 -0
  109. data/lib/plutonium/invites/pending_invite_check.rb +76 -0
  110. data/lib/plutonium/invites.rb +6 -0
  111. data/lib/plutonium/resource/controllers/queryable.rb +4 -0
  112. data/lib/plutonium/resource/query_object.rb +3 -5
  113. data/lib/plutonium/version.rb +1 -1
  114. data/package.json +1 -1
  115. metadata +64 -7
  116. data/lib/generators/pu/res/entity/entity_generator.rb +0 -158
  117. data/lib/generators/pu/rodauth/customer_generator.rb +0 -101
  118. data/public/plutonium-assets/plutonium-logo-original.png +0 -0
  119. data/public/plutonium-assets/plutonium-logo-white.png +0 -0
  120. data/public/plutonium-assets/plutonium-logo.png +0 -0
@@ -0,0 +1,45 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
5
+ <style>
6
+ .email-container {
7
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
8
+ max-width: 600px;
9
+ margin: 0 auto;
10
+ padding: 20px;
11
+ }
12
+ .button {
13
+ display: inline-block;
14
+ padding: 12px 24px;
15
+ background-color: #3b82f6;
16
+ color: white;
17
+ text-decoration: none;
18
+ border-radius: 6px;
19
+ margin: 20px 0;
20
+ }
21
+ </style>
22
+ </head>
23
+ <body>
24
+ <div class="email-container">
25
+ <h2>You've been invited to <%%= @user_invite.entity.to_label %></h2>
26
+
27
+ <p>Hi there,</p>
28
+
29
+ <p><%%= @user_invite.invited_by.email %> has invited you to join <%%= @user_invite.entity.to_label %> as a <%= model_class.titleize.downcase %>.</p>
30
+
31
+ <p>Click the link below to accept your invitation:</p>
32
+
33
+ <%%= link_to "Accept Invitation", @invitation_url, class: "button" %>
34
+
35
+ <p>Or copy and paste this URL into your browser:</p>
36
+ <p><%%= @invitation_url %></p>
37
+
38
+ <%% if @user_invite.expires_at.present? %>
39
+ <p><small>This invitation will expire on <%%= @user_invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %></small></p>
40
+ <%% end %>
41
+
42
+ <p>If you didn't expect this invitation, you can safely ignore this email.</p>
43
+ </div>
44
+ </body>
45
+ </html>
@@ -0,0 +1,15 @@
1
+ You've been invited to <%%= @user_invite.entity.to_label %>
2
+
3
+ Hi there,
4
+
5
+ <%%= @user_invite.invited_by.email %> has invited you to join <%%= @user_invite.entity.to_label %> as a <%= model_class.titleize.downcase %>.
6
+
7
+ Click the link below to accept your invitation:
8
+
9
+ <%%= @invitation_url %>
10
+
11
+ <%% if @user_invite.expires_at.present? %>
12
+ This invitation will expire on <%%= @user_invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %>
13
+ <%% end %>
14
+
15
+ If you didn't expect this invitation, you can safely ignore this email.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= model_class %>::InviteUserInteraction < Plutonium::Resource::Interaction
4
+ include Plutonium::Invites::Concerns::InviteUser
5
+
6
+ input :email
7
+
8
+ private
9
+
10
+ def entity
11
+ current_entity
12
+ end
13
+
14
+ def invitable
15
+ resource
16
+ end
17
+
18
+ def role
19
+ :<%= role %>
20
+ end
21
+ <% if membership_model != "EntityUser" -%>
22
+
23
+ def membership_class
24
+ <%= membership_model %>
25
+ end
26
+ <% end -%>
27
+ <% if user_model != "User" -%>
28
+
29
+ def user_class
30
+ <%= user_model %>
31
+ end
32
+ <% end -%>
33
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invites
4
+ class UserInvitationsController < ApplicationController
5
+ include Plutonium::Invites::Controller
6
+
7
+ layout "invites/invitation"
8
+
9
+ private
10
+
11
+ def invite_class
12
+ Invites::UserInvite
13
+ end
14
+
15
+ def user_class
16
+ <%= user_model %>
17
+ end
18
+
19
+ def after_accept_path
20
+ "/"
21
+ end
22
+
23
+ def login_path
24
+ <% if rodauth? -%>
25
+ rodauth(:<%= rodauth_config %>).login_path
26
+ <% else -%>
27
+ "/login"
28
+ <% end -%>
29
+ end
30
+
31
+ def current_user
32
+ <% if rodauth? -%>
33
+ rodauth(:<%= rodauth_config %>).rails_account if rodauth(:<%= rodauth_config %>).logged_in?
34
+ <% else -%>
35
+ # TODO: Implement based on your authentication system
36
+ nil
37
+ <% end -%>
38
+ end
39
+
40
+ <% if rodauth? -%>
41
+ def create_user_for_signup(email, password)
42
+ rodauth_instance = rodauth(:<%= rodauth_config %>)
43
+ password_hash = rodauth_instance.password_hash(password)
44
+
45
+ if email.downcase == @invite.email.downcase
46
+ # Email matches invitation - create verified account directly
47
+ <%= user_model %>.create!(
48
+ email: email,
49
+ password_hash: password_hash,
50
+ status: 2 # verified
51
+ )
52
+ else
53
+ # Different email - normal flow with verification email
54
+ rodauth_instance.create_account(login: email, password: password)
55
+ <%= user_model %>.find_by(email: email)
56
+ end
57
+ end
58
+
59
+ def sign_in_user(user)
60
+ rodauth(:<%= rodauth_config %>).account_from_login(user.email)
61
+ rodauth(:<%= rodauth_config %>).login("signup")
62
+ end
63
+
64
+ def rodauth(name = :<%= rodauth_config %>)
65
+ request.env["rodauth.#{name}"]
66
+ end
67
+ <% else -%>
68
+ def create_user_for_signup(email, password)
69
+ raise NotImplementedError, "Implement create_user_for_signup based on your auth system"
70
+ end
71
+
72
+ def sign_in_user(user)
73
+ # Implement based on your authentication system
74
+ end
75
+ <% end -%>
76
+ end
77
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invites
4
+ class WelcomeController < ApplicationController
5
+ include Plutonium::Invites::PendingInviteCheck
6
+
7
+ layout "invites/invitation"
8
+
9
+ <% if rodauth? -%>
10
+ before_action :require_authentication
11
+ <% end -%>
12
+
13
+ def index
14
+ @invite = pending_invite
15
+
16
+ if @invite
17
+ render :pending_invitation
18
+ else
19
+ redirect_to after_welcome_path, allow_other_host: false
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def invite_class
26
+ Invites::UserInvite
27
+ end
28
+
29
+ # Returns the path to redirect to after the welcome flow completes.
30
+ # This reads from session[:after_welcome_redirect] which should be set
31
+ # by the after_login hook in Rodauth.
32
+ def after_welcome_path
33
+ session.delete(:after_welcome_redirect) || default_redirect_path
34
+ end
35
+
36
+ def default_redirect_path
37
+ "/"
38
+ end
39
+
40
+ <% if rodauth? -%>
41
+ def require_authentication
42
+ redirect_to login_path unless current_user
43
+ end
44
+
45
+ def current_user
46
+ rodauth(:<%= rodauth_config %>).rails_account if rodauth(:<%= rodauth_config %>).logged_in?
47
+ end
48
+
49
+ def login_path
50
+ rodauth(:<%= rodauth_config %>).login_path
51
+ end
52
+
53
+ def rodauth(name = :<%= rodauth_config %>)
54
+ request.env["rodauth.#{name}"]
55
+ end
56
+ <% else -%>
57
+ def require_authentication
58
+ # TODO: Implement based on your authentication system
59
+ redirect_to "/login" unless current_user
60
+ end
61
+
62
+ def current_user
63
+ # TODO: Implement based on your authentication system
64
+ nil
65
+ end
66
+ <% end -%>
67
+ end
68
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invites
4
+ class UserInviteDefinition < Invites::ResourceDefinition
5
+ action :resend, interaction: Invites::ResendInviteInteraction, collection_record_action: false
6
+ action :cancel, interaction: Invites::CancelInviteInteraction, collection_record_action: false
7
+
8
+ search do |scope, query|
9
+ scope.where("email ILIKE ?", "%#{query}%")
10
+ end
11
+
12
+ # State scopes
13
+ scope :pending
14
+ scope :accepted
15
+ scope :expired
16
+ scope :cancelled
17
+
18
+ # Role scopes
19
+ <% roles.each do |role| -%>
20
+ scope :<%= role %>
21
+ <% end -%>
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invites
4
+ class CancelInviteInteraction < Plutonium::Resource::Interaction
5
+ include Plutonium::Invites::Concerns::CancelInvite
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invites
4
+ class ResendInviteInteraction < Invites::ResourceInteraction
5
+ include Plutonium::Invites::Concerns::ResendInvite
6
+ end
7
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invites
4
+ class UserInviteMailer < ApplicationMailer
5
+ def invitation(user_invite)
6
+ @user_invite = user_invite
7
+ @invitation_url = invitation_url(token: user_invite.token)
8
+
9
+ mail(
10
+ to: user_invite.email,
11
+ subject: invitation_subject,
12
+ template_name: invitation_template_name
13
+ )
14
+ end
15
+
16
+ private
17
+
18
+ def invitation_subject
19
+ "You've been invited to join #{@user_invite.entity.to_label}"
20
+ end
21
+
22
+ def invitation_template_name
23
+ return "invitation" unless @user_invite.invitable_type.present?
24
+
25
+ # e.g., "TenantProfile" -> "invitation_tenant_profile"
26
+ template = "invitation_#{@user_invite.invitable_type.underscore}"
27
+ template_exists?(template) ? template : "invitation"
28
+ end
29
+
30
+ def template_exists?(name)
31
+ lookup_context.exists?(name, _prefixes, false, [], formats: [:html, :text])
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invites
4
+ class UserInvite < Invites::ResourceRecord
5
+ include Plutonium::Invites::Concerns::InviteToken
6
+
7
+ enum :role, <%= roles_enum %>
8
+
9
+ encrypts :token, deterministic: true
10
+
11
+ belongs_to :<%= entity_table %>
12
+ belongs_to :invited_by, polymorphic: true
13
+ belongs_to :<%= user_table %>, optional: true
14
+ belongs_to :invitable, polymorphic: true, optional: true
15
+
16
+ validates :role, presence: true
17
+
18
+ # Implement required methods from InviteToken concern
19
+
20
+ def invitation_mailer
21
+ Invites::UserInviteMailer
22
+ end
23
+
24
+ def enforce_domain
25
+ <% if enforce_domain? -%>
26
+ raise NotImplementedError, "#{self.class}#enforce_domain must return the domain to enforce"
27
+ <% else -%>
28
+ nil
29
+ <% end -%>
30
+ end
31
+
32
+ def create_membership_for(user)
33
+ <%= membership_model %>.create!(<%= entity_table %>: <%= entity_table %>, user: user, role: role)
34
+ end
35
+ <% if entity_table != "entity" -%>
36
+
37
+ # Alias for InviteToken concern compatibility
38
+ alias_method :entity, :<%= entity_table %>
39
+ <% end -%>
40
+ end
41
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invites
4
+ class UserInvitePolicy < Invites::ResourcePolicy
5
+ def create?
6
+ false # Created via InviteUserInteraction
7
+ end
8
+
9
+ def read?
10
+ true
11
+ end
12
+
13
+ def cancel?
14
+ record.pending?
15
+ end
16
+
17
+ def resend?
18
+ record.pending?
19
+ end
20
+
21
+ def permitted_attributes_for_create
22
+ [:entity, :email, :role, :invited_by, :expires_at]
23
+ end
24
+
25
+ def permitted_attributes_for_read
26
+ [:entity, :email, :role, :state, :invited_by, :expires_at, :accepted_at, :<%= user_table %>]
27
+ end
28
+
29
+ def permitted_associations
30
+ []
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,24 @@
1
+ <div class="text-center">
2
+ <div class="mb-4">
3
+ <svg class="mx-auto h-12 w-12 text-danger-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
4
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
5
+ </svg>
6
+ </div>
7
+
8
+ <h1 class="text-lg font-bold leading-tight tracking-tight text-[var(--pu-text)] md:text-xl mb-2">
9
+ <%%= @error_title %>
10
+ </h1>
11
+
12
+ <p class="text-[var(--pu-text-muted)] mb-6"><%%= @error_message %></p>
13
+
14
+ <div class="space-y-3">
15
+ <%%= link_to "Go to Home", "/",
16
+ class: "inline-block pu-btn pu-btn-md pu-btn-primary" %>
17
+
18
+ <%% unless current_user %>
19
+ <p class="text-sm text-[var(--pu-text-subtle)] mt-4">
20
+ Need a new invitation? Contact your administrator.
21
+ </p>
22
+ <%% end %>
23
+ </div>
24
+ </div>
@@ -0,0 +1,40 @@
1
+ <h1 class="text-lg font-bold leading-tight tracking-tight text-[var(--pu-text)] md:text-xl text-center">
2
+ You're Invited!
3
+ </h1>
4
+
5
+ <div class="mb-6 p-4 bg-[var(--pu-surface-alt)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)]">
6
+ <div class="text-center">
7
+ <p class="text-[var(--pu-text-muted)] mb-2">You've been invited to join:</p>
8
+ <p class="text-xl font-bold text-primary-600 dark:text-primary-400"><%%= @invite.entity.to_label %></p>
9
+
10
+ <div class="mt-3 text-sm text-[var(--pu-text-muted)]">
11
+ <p><strong>Role:</strong> <%%= @invite.role.capitalize %></p>
12
+ <p><strong>Invited by:</strong> <%%= @invite.invited_by.email %></p>
13
+ <%% if @invite.expires_at.present? %>
14
+ <p><strong>Expires:</strong> <%%= @invite.expires_at.strftime("%B %d, %Y") %></p>
15
+ <%% end %>
16
+ </div>
17
+ </div>
18
+ </div>
19
+
20
+ <div class="space-y-4">
21
+ <div class="text-center">
22
+ <p class="text-[var(--pu-text-muted)] mb-4">To accept this invitation, please:</p>
23
+ </div>
24
+
25
+ <%%= link_to "Create Account", invitation_signup_path(token: params[:token]),
26
+ class: "w-full block pu-btn pu-btn-md pu-btn-primary text-center" %>
27
+
28
+ <div class="text-center">
29
+ <span class="text-[var(--pu-text-subtle)]">or</span>
30
+ </div>
31
+
32
+ <%%= link_to "Sign In", login_path,
33
+ class: "w-full block pu-btn pu-btn-md pu-btn-outline text-center" %>
34
+
35
+ <div class="mt-6 pt-4 border-t border-[var(--pu-border)] text-center">
36
+ <p class="text-xs text-[var(--pu-text-subtle)]">
37
+ Don't want to join? You can simply ignore this invitation.
38
+ </p>
39
+ </div>
40
+ </div>
@@ -0,0 +1,39 @@
1
+ <h1 class="text-lg font-bold leading-tight tracking-tight text-[var(--pu-text)] md:text-xl">
2
+ Accept Invitation
3
+ </h1>
4
+
5
+ <div class="mb-6 p-4 bg-[var(--pu-surface-alt)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)]">
6
+ <div class="text-center">
7
+ <p class="text-[var(--pu-text-muted)] mb-2">You've been invited to join:</p>
8
+ <p class="text-xl font-semibold text-primary-600 dark:text-primary-400"><%%= @invite.entity.to_label %></p>
9
+ </div>
10
+ </div>
11
+
12
+ <div class="space-y-4">
13
+ <div>
14
+ <p class="text-[var(--pu-text-muted)] mb-2">Role:</p>
15
+ <p class="text-lg capitalize font-medium text-[var(--pu-text)]"><%%= @invite.role %></p>
16
+ </div>
17
+
18
+ <div>
19
+ <p class="text-[var(--pu-text-muted)] mb-2">Invited by:</p>
20
+ <p class="text-lg font-medium text-[var(--pu-text)]"><%%= @invite.invited_by.email %></p>
21
+ </div>
22
+
23
+ <%% if @invite.expires_at.present? %>
24
+ <div class="p-3 bg-warning-50 dark:bg-warning-900/20 border border-warning-200 dark:border-warning-800 rounded-[var(--pu-radius-md)]">
25
+ <p class="text-sm text-warning-800 dark:text-warning-200">
26
+ This invitation expires on <%%= @invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %>
27
+ </p>
28
+ </div>
29
+ <%% end %>
30
+
31
+ <%%= form_with url: accept_invitation_path(token: params[:token]), method: :post, local: true do |form| %>
32
+ <div class="space-y-3 pt-4">
33
+ <%%= form.submit "Accept Invitation",
34
+ class: "w-full pu-btn pu-btn-md pu-btn-primary cursor-pointer" %>
35
+ <%%= link_to "Cancel", "/",
36
+ class: "w-full block pu-btn pu-btn-md pu-btn-outline text-center" %>
37
+ </div>
38
+ <%% end %>
39
+ </div>
@@ -0,0 +1,49 @@
1
+ <div class="mb-6 p-4 bg-[var(--pu-surface-alt)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)]">
2
+ <div class="text-center">
3
+ <p class="text-sm text-[var(--pu-text-muted)] mb-1">Joining:</p>
4
+ <p class="font-bold text-primary-600 dark:text-primary-400"><%%= @invite.entity.to_label %></p>
5
+ <p class="text-sm text-[var(--pu-text-muted)] mt-1">as <%%= @invite.role.capitalize %></p>
6
+ </div>
7
+ </div>
8
+
9
+ <h1 class="text-lg font-bold leading-tight tracking-tight text-[var(--pu-text)] md:text-xl">
10
+ Create Your Account
11
+ </h1>
12
+
13
+ <%%= form_with url: invitation_signup_path(token: @invite.token), method: :post, local: true, class: "space-y-4", data: { turbo: false } do |form| %>
14
+ <div>
15
+ <label for="email" class="pu-label">Email</label>
16
+ <%% if @invite.enforce_email? %>
17
+ <input type="email" name="email" value="<%%= @invite.email %>" disabled
18
+ class="pu-input cursor-not-allowed opacity-60">
19
+ <input type="hidden" name="email" value="<%%= @invite.email %>">
20
+ <p class="pu-hint">This email is required for your invitation</p>
21
+ <%% else %>
22
+ <%%= form.email_field :email, value: @invite.email, required: true,
23
+ placeholder: "you@example.com",
24
+ class: "pu-input" %>
25
+ <%% if (domain = @invite.enforce_domain) %>
26
+ <p class="pu-hint">Must be from <%%= domain %> domain</p>
27
+ <%% else %>
28
+ <p class="pu-hint">You can use a different email if needed</p>
29
+ <%% end %>
30
+ <%% end %>
31
+ </div>
32
+
33
+ <div>
34
+ <label for="password" class="pu-label">Password</label>
35
+ <%%= form.password_field :password, required: true, class: "pu-input" %>
36
+ </div>
37
+
38
+ <div>
39
+ <label for="password_confirmation" class="pu-label">Confirm Password</label>
40
+ <%%= form.password_field :password_confirmation, required: true, class: "pu-input" %>
41
+ </div>
42
+
43
+ <div class="space-y-3 pt-4">
44
+ <%%= form.submit "Create Account",
45
+ class: "w-full pu-btn pu-btn-md pu-btn-primary cursor-pointer" %>
46
+ <%%= link_to "Back", invitation_path(token: @invite.token),
47
+ class: "w-full block pu-btn pu-btn-md pu-btn-outline text-center" %>
48
+ </div>
49
+ <%% end %>
@@ -0,0 +1,45 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
5
+ <style>
6
+ .email-container {
7
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
8
+ max-width: 600px;
9
+ margin: 0 auto;
10
+ padding: 20px;
11
+ }
12
+ .button {
13
+ display: inline-block;
14
+ padding: 12px 24px;
15
+ background-color: #3b82f6;
16
+ color: white;
17
+ text-decoration: none;
18
+ border-radius: 6px;
19
+ margin: 20px 0;
20
+ }
21
+ </style>
22
+ </head>
23
+ <body>
24
+ <div class="email-container">
25
+ <h2>You've been invited to <%%= @user_invite.entity.to_label %></h2>
26
+
27
+ <p>Hi there,</p>
28
+
29
+ <p><%%= @user_invite.invited_by.email %> has invited you to join <%%= @user_invite.entity.to_label %> as a <%%= @user_invite.role %>.</p>
30
+
31
+ <p>Click the link below to accept your invitation:</p>
32
+
33
+ <%%= link_to "Accept Invitation", @invitation_url, class: "button" %>
34
+
35
+ <p>Or copy and paste this URL into your browser:</p>
36
+ <p><%%= @invitation_url %></p>
37
+
38
+ <%% if @user_invite.expires_at.present? %>
39
+ <p><small>This invitation will expire on <%%= @user_invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %></small></p>
40
+ <%% end %>
41
+
42
+ <p>If you didn't expect this invitation, you can safely ignore this email.</p>
43
+ </div>
44
+ </body>
45
+ </html>
@@ -0,0 +1,15 @@
1
+ You've been invited to <%%= @user_invite.entity.to_label %>
2
+ ======================================================
3
+
4
+ Hi there,
5
+
6
+ <%%= @user_invite.invited_by.email %> has invited you to join <%%= @user_invite.entity.to_label %> as a <%%= @user_invite.role %>.
7
+
8
+ Accept your invitation by visiting this link:
9
+ <%%= @invitation_url %>
10
+
11
+ <%% if @user_invite.expires_at.present? %>
12
+ This invitation will expire on <%%= @user_invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %>
13
+ <%% end %>
14
+
15
+ If you didn't expect this invitation, you can safely ignore this email.
@@ -0,0 +1,23 @@
1
+ <h1 class="text-lg font-bold leading-tight tracking-tight text-[var(--pu-text)] md:text-xl text-center">
2
+ Pending Invitation Found
3
+ </h1>
4
+
5
+ <div class="mb-6 p-4 bg-[var(--pu-surface-alt)] border border-[var(--pu-border)] rounded-[var(--pu-radius-lg)]">
6
+ <div class="text-center">
7
+ <p class="text-[var(--pu-text-muted)] mb-2">You have a pending invitation to join:</p>
8
+ <p class="text-xl font-bold text-primary-600 dark:text-primary-400"><%%= @invite.entity.to_label %></p>
9
+
10
+ <div class="mt-3 text-sm text-[var(--pu-text-muted)]">
11
+ <p><strong>Role:</strong> <%%= @invite.role.capitalize %></p>
12
+ <p><strong>Invited by:</strong> <%%= @invite.invited_by.email %></p>
13
+ </div>
14
+ </div>
15
+ </div>
16
+
17
+ <div class="space-y-4">
18
+ <%%= link_to "View Invitation", invitation_path(token: @invite.token),
19
+ class: "w-full block pu-btn pu-btn-md pu-btn-primary text-center" %>
20
+
21
+ <%%= link_to "Skip for Now", "/",
22
+ class: "w-full block pu-btn pu-btn-md pu-btn-outline text-center" %>
23
+ </div>
@@ -0,0 +1,33 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Invitation</title>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <meta name="csrf-param" content="authenticity_token" />
8
+ <meta name="csrf-token" content="<%%= form_authenticity_token %>" />
9
+ <%%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
10
+ <%%= javascript_importmap_tags %>
11
+ </head>
12
+ <body class="antialiased min-h-screen bg-[var(--pu-body)]">
13
+ <main class="p-4 min-h-screen flex flex-col items-center justify-center gap-2 px-6 py-8 mx-auto lg:py-0">
14
+ <%% if flash.any? %>
15
+ <%% flash.each do |type, message| %>
16
+ <div class="fixed z-50 top-16 inset-x-0 mx-auto flex items-center w-full max-w-md p-4 rounded-[var(--pu-radius-lg)]"
17
+ style="box-shadow: var(--pu-shadow-md); background: <%%= type == 'alert' ? 'var(--color-danger-50)' : 'var(--color-success-50)' %>; color: <%%= type == 'alert' ? 'var(--color-danger-600)' : 'var(--color-success-600)' %>"
18
+ role="alert">
19
+ <div class="ms-3 text-sm font-normal"><%%= message %></div>
20
+ </div>
21
+ <%% end %>
22
+ <%% end %>
23
+
24
+ <!-- Main content card -->
25
+ <div class="w-full sm:max-w-md bg-[var(--pu-card-bg)] border border-[var(--pu-card-border)] rounded-[var(--pu-radius-lg)]"
26
+ style="box-shadow: var(--pu-shadow-lg)">
27
+ <div class="p-6 space-y-4 md:space-y-6 sm:p-8">
28
+ <%%= yield %>
29
+ </div>
30
+ </div>
31
+ </main>
32
+ </body>
33
+ </html>