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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-connect-resource/SKILL.md +19 -1
- data/.claude/skills/plutonium-controller/SKILL.md +5 -9
- data/.claude/skills/plutonium-definition-query/SKILL.md +10 -2
- data/.claude/skills/plutonium-installation/SKILL.md +9 -7
- data/.claude/skills/plutonium-invites/SKILL.md +363 -0
- data/.claude/skills/plutonium-package/SKILL.md +2 -1
- data/.claude/skills/plutonium-portal/SKILL.md +30 -16
- data/.claude/skills/plutonium-rodauth/SKILL.md +111 -18
- data/CHANGELOG.md +48 -0
- data/app/assets/plutonium.css +1 -1
- data/config/initializers/sqlite_alias.rb +8 -8
- data/docs/.vitepress/config.ts +1 -0
- data/docs/getting-started/tutorial/07-author-portal.md +1 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +5 -2
- data/docs/guides/adding-resources.md +10 -0
- data/docs/guides/authentication.md +15 -8
- data/docs/guides/creating-packages.md +13 -8
- data/docs/guides/index.md +2 -0
- data/docs/guides/search-filtering.md +8 -3
- data/docs/guides/user-invites.md +497 -0
- data/docs/public/templates/base.rb +5 -1
- data/docs/public/templates/lite.rb +42 -0
- data/docs/public/templates/pluton8.rb +7 -2
- data/docs/reference/controller/index.md +12 -7
- data/docs/reference/definition/query.md +12 -3
- data/docs/reference/generators/index.md +70 -10
- data/docs/reference/portal/index.md +22 -11
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +31 -0
- data/lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt +58 -0
- data/lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake +6 -1
- data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -0
- data/lib/generators/pu/invites/USAGE +27 -0
- data/lib/generators/pu/invites/install_generator.rb +364 -0
- data/lib/generators/pu/invites/invitable/USAGE +31 -0
- data/lib/generators/pu/invites/invitable_generator.rb +143 -0
- data/lib/generators/pu/invites/templates/INSTRUCTIONS +22 -0
- data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +24 -0
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +26 -0
- data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +47 -0
- data/lib/generators/pu/invites/templates/invitable/invitation.html.erb.tt +45 -0
- data/lib/generators/pu/invites/templates/invitable/invitation.text.erb.tt +15 -0
- data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +33 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +77 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +68 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +23 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +7 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/resend_invite_interaction.rb.tt +7 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +34 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +41 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +33 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/error.html.erb.tt +24 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +40 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +39 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +49 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +45 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +15 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +23 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +33 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +23 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +130 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +72 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -2
- data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +7 -1
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +105 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +88 -0
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +14 -0
- data/lib/generators/pu/lite/setup/setup_generator.rb +54 -0
- data/lib/generators/pu/lite/solid_cable/solid_cable_generator.rb +65 -0
- data/lib/generators/pu/lite/solid_cache/solid_cache_generator.rb +66 -0
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +61 -0
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +107 -0
- data/lib/generators/pu/pkg/portal/USAGE +8 -2
- data/lib/generators/pu/pkg/portal/portal_generator.rb +11 -1
- data/lib/generators/pu/pkg/portal/templates/app/controllers/concerns/controller.rb.tt +2 -0
- data/lib/generators/pu/pkg/portal/templates/app/controllers/plutonium_controller.rb.tt +1 -0
- data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +7 -0
- data/lib/generators/pu/pkg/portal/templates/lib/engine.rb.tt +3 -0
- data/lib/generators/pu/res/conn/USAGE +5 -0
- data/lib/generators/pu/res/conn/conn_generator.rb +30 -4
- data/lib/generators/pu/res/scaffold/scaffold_generator.rb +6 -3
- data/lib/generators/pu/res/scaffold/templates/policy.rb.tt +6 -6
- data/lib/generators/pu/rodauth/account_generator.rb +36 -11
- data/lib/generators/pu/rodauth/admin_generator.rb +55 -0
- data/lib/generators/pu/rodauth/install_generator.rb +1 -8
- data/lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt +25 -0
- data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +6 -2
- data/lib/generators/pu/saas/USAGE +22 -0
- data/lib/generators/pu/saas/entity/USAGE +19 -0
- data/lib/generators/pu/saas/entity_generator.rb +55 -0
- data/lib/generators/pu/saas/membership/USAGE +25 -0
- data/lib/generators/pu/saas/membership_generator.rb +165 -0
- data/lib/generators/pu/saas/setup/USAGE +27 -0
- data/lib/generators/pu/saas/setup_generator.rb +98 -0
- data/lib/generators/pu/saas/user/USAGE +21 -0
- data/lib/generators/pu/saas/user_generator.rb +66 -0
- data/lib/plutonium/core/controller.rb +9 -5
- data/lib/plutonium/definition/base.rb +3 -1
- data/lib/plutonium/definition/scoping.rb +20 -0
- data/lib/plutonium/invites/concerns/cancel_invite.rb +44 -0
- data/lib/plutonium/invites/concerns/invitable.rb +98 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +186 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +147 -0
- data/lib/plutonium/invites/concerns/resend_invite.rb +66 -0
- data/lib/plutonium/invites/controller.rb +226 -0
- data/lib/plutonium/invites/pending_invite_check.rb +76 -0
- data/lib/plutonium/invites.rb +6 -0
- data/lib/plutonium/resource/controllers/queryable.rb +4 -0
- data/lib/plutonium/resource/query_object.rb +3 -5
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- metadata +64 -7
- data/lib/generators/pu/res/entity/entity_generator.rb +0 -158
- data/lib/generators/pu/rodauth/customer_generator.rb +0 -101
- data/public/plutonium-assets/plutonium-logo-original.png +0 -0
- data/public/plutonium-assets/plutonium-logo-white.png +0 -0
- 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,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
|
data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt
ADDED
|
@@ -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>
|