plutonium 0.34.1 → 0.35.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/skill.md +53 -0
- data/.claude/skills/{assets → plutonium-assets}/SKILL.md +13 -8
- data/.claude/skills/{connect-resource → plutonium-connect-resource}/SKILL.md +1 -1
- data/.claude/skills/{controller → plutonium-controller}/SKILL.md +27 -13
- data/.claude/skills/{create-resource → plutonium-create-resource}/SKILL.md +1 -1
- data/.claude/skills/{definition → plutonium-definition}/SKILL.md +10 -10
- data/.claude/skills/{definition-actions → plutonium-definition-actions}/SKILL.md +34 -9
- data/.claude/skills/{definition-fields → plutonium-definition-fields}/SKILL.md +38 -10
- data/.claude/skills/plutonium-definition-query/SKILL.md +356 -0
- data/.claude/skills/{forms → plutonium-forms}/SKILL.md +6 -6
- data/.claude/skills/{installation → plutonium-installation}/SKILL.md +9 -9
- data/.claude/skills/{interaction → plutonium-interaction}/SKILL.md +20 -19
- data/.claude/skills/{model → plutonium-model}/SKILL.md +3 -3
- data/.claude/skills/{model-features → plutonium-model-features}/SKILL.md +3 -3
- data/.claude/skills/{nested-resources → plutonium-nested-resources}/SKILL.md +5 -5
- data/.claude/skills/{package → plutonium-package}/SKILL.md +7 -8
- data/.claude/skills/{policy → plutonium-policy}/SKILL.md +26 -4
- data/.claude/skills/{portal → plutonium-portal}/SKILL.md +33 -31
- data/.claude/skills/{resource → plutonium-resource}/SKILL.md +27 -27
- data/.claude/skills/{rodauth → plutonium-rodauth}/SKILL.md +5 -5
- data/.claude/skills/plutonium-theming/SKILL.md +424 -0
- data/.claude/skills/{views → plutonium-views}/SKILL.md +7 -7
- data/CHANGELOG.md +52 -0
- data/CLAUDE.md +215 -0
- data/CONTRIBUTING.md +72 -18
- data/README.md +100 -19
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1685 -1146
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +70 -70
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/resource/interactive_bulk_action.html.erb +1 -5
- data/app/views/rodauth/_email_auth_request_form.html.erb +1 -1
- data/app/views/rodauth/_login_form.html.erb +15 -55
- data/app/views/rodauth/_login_form_footer.html.erb +2 -2
- data/app/views/rodauth/_password_visibility.html.erb +2 -8
- data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
- data/app/views/rodauth/change_login.html.erb +36 -19
- data/app/views/rodauth/change_password.html.erb +34 -10
- data/app/views/rodauth/close_account.html.erb +12 -4
- data/app/views/rodauth/confirm_password.html.erb +19 -17
- data/app/views/rodauth/create_account.html.erb +30 -109
- data/app/views/rodauth/email_auth.html.erb +1 -1
- data/app/views/rodauth/logout.html.erb +4 -4
- data/app/views/rodauth/otp_auth.html.erb +13 -4
- data/app/views/rodauth/otp_disable.html.erb +12 -4
- data/app/views/rodauth/otp_setup.html.erb +29 -12
- data/app/views/rodauth/otp_unlock.html.erb +19 -10
- data/app/views/rodauth/otp_unlock_not_available.html.erb +7 -7
- data/app/views/rodauth/recovery_auth.html.erb +12 -4
- data/app/views/rodauth/recovery_codes.html.erb +12 -4
- data/app/views/rodauth/remember.html.erb +7 -7
- data/app/views/rodauth/reset_password.html.erb +23 -7
- data/app/views/rodauth/reset_password_request.html.erb +14 -10
- data/app/views/rodauth/sms_auth.html.erb +13 -4
- data/app/views/rodauth/sms_confirm.html.erb +13 -4
- data/app/views/rodauth/sms_disable.html.erb +12 -4
- data/app/views/rodauth/sms_request.html.erb +1 -1
- data/app/views/rodauth/sms_setup.html.erb +23 -7
- data/app/views/rodauth/two_factor_auth.html.erb +2 -2
- data/app/views/rodauth/two_factor_disable.html.erb +12 -4
- data/app/views/rodauth/two_factor_manage.html.erb +7 -7
- data/app/views/rodauth/unlock_account.html.erb +13 -5
- data/app/views/rodauth/unlock_account_request.html.erb +2 -2
- data/app/views/rodauth/verify_account.html.erb +25 -7
- data/app/views/rodauth/verify_account_resend.html.erb +14 -10
- data/app/views/rodauth/verify_login_change.html.erb +1 -1
- data/app/views/rodauth/webauthn_auth.html.erb +1 -1
- data/app/views/rodauth/webauthn_remove.html.erb +18 -8
- data/app/views/rodauth/webauthn_setup.html.erb +12 -4
- data/docs/.vitepress/config.ts +15 -26
- data/docs/.vitepress/theme/custom.css +388 -29
- data/docs/getting-started/index.md +1 -1
- data/docs/getting-started/tutorial/02-first-resource.md +9 -0
- data/docs/getting-started/tutorial/06-nested-resources.md +2 -2
- data/docs/getting-started/tutorial/07-author-portal.md +191 -0
- data/docs/getting-started/tutorial/{07-customizing-ui.md → 08-customizing-ui.md} +7 -7
- data/docs/getting-started/tutorial/index.md +5 -2
- data/docs/guides/authorization.md +33 -0
- data/docs/guides/creating-packages.md +12 -16
- data/docs/guides/custom-actions.md +36 -0
- data/docs/guides/search-filtering.md +121 -42
- data/docs/guides/theming.md +232 -36
- data/docs/index.md +203 -57
- data/docs/public/og-image.png +0 -0
- data/docs/reference/controller/index.md +14 -16
- data/docs/reference/definition/actions.md +38 -3
- data/docs/reference/definition/fields.md +3 -3
- data/docs/reference/definition/index.md +2 -2
- data/docs/reference/generators/index.md +0 -1
- data/docs/reference/interaction/index.md +14 -10
- data/docs/reference/model/index.md +0 -1
- data/docs/reference/portal/index.md +13 -27
- 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/pkg/portal/portal_generator.rb +0 -2
- data/lib/generators/pu/pkg/portal/templates/app/views/package/dashboard/index.html.erb +28 -72
- data/lib/plutonium/action/interactive.rb +2 -2
- data/lib/plutonium/core/controller.rb +2 -1
- data/lib/plutonium/definition/actions.rb +2 -2
- data/lib/plutonium/lib/deep_freezer.rb +3 -7
- data/lib/plutonium/query/filter.rb +14 -0
- data/lib/plutonium/query/filters/association.rb +49 -0
- data/lib/plutonium/query/filters/boolean.rb +35 -0
- data/lib/plutonium/query/filters/date.rb +97 -0
- data/lib/plutonium/query/filters/date_range.rb +58 -0
- data/lib/plutonium/query/filters/select.rb +55 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +24 -6
- data/lib/plutonium/resource/controllers/interactive_actions.rb +76 -58
- data/lib/plutonium/resource/controllers/queryable.rb +4 -2
- data/lib/plutonium/resource/query_object.rb +1 -1
- data/lib/plutonium/ui/action_button.rb +23 -65
- data/lib/plutonium/ui/actions_dropdown.rb +103 -0
- data/lib/plutonium/ui/block.rb +1 -1
- data/lib/plutonium/ui/breadcrumbs.rb +12 -19
- data/lib/plutonium/ui/color_mode_selector.rb +1 -1
- data/lib/plutonium/ui/component/kit.rb +6 -0
- data/lib/plutonium/ui/component_classes.rb +102 -0
- data/lib/plutonium/ui/display/base.rb +15 -0
- data/lib/plutonium/ui/display/components/attachment.rb +6 -5
- data/lib/plutonium/ui/display/components/boolean.rb +23 -0
- data/lib/plutonium/ui/display/components/color.rb +23 -0
- data/lib/plutonium/ui/display/resource.rb +1 -1
- data/lib/plutonium/ui/display/theme.rb +29 -15
- data/lib/plutonium/ui/empty_card.rb +3 -3
- data/lib/plutonium/ui/form/base.rb +20 -0
- data/lib/plutonium/ui/form/components/key_value_store.rb +11 -11
- data/lib/plutonium/ui/form/components/resource_select.rb +31 -0
- data/lib/plutonium/ui/form/components/secure_association.rb +1 -2
- data/lib/plutonium/ui/form/components/uppy.rb +5 -4
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +4 -4
- data/lib/plutonium/ui/form/interaction.rb +17 -1
- data/lib/plutonium/ui/form/query.rb +133 -80
- data/lib/plutonium/ui/form/theme.rb +50 -35
- data/lib/plutonium/ui/frame_navigator_panel.rb +2 -2
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/layout/header.rb +4 -7
- data/lib/plutonium/ui/layout/rodauth_layout.rb +7 -7
- data/lib/plutonium/ui/layout/sidebar.rb +1 -1
- data/lib/plutonium/ui/nav_grid_menu.rb +7 -6
- data/lib/plutonium/ui/nav_user.rb +9 -8
- data/lib/plutonium/ui/page/interactive_action.rb +5 -5
- data/lib/plutonium/ui/page_header.rb +29 -10
- data/lib/plutonium/ui/panel.rb +4 -4
- data/lib/plutonium/ui/sidebar_menu.rb +8 -8
- data/lib/plutonium/ui/skeleton_table.rb +7 -8
- data/lib/plutonium/ui/tab_list.rb +5 -5
- data/lib/plutonium/ui/table/base.rb +3 -0
- data/lib/plutonium/ui/table/components/attachment.rb +4 -3
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +82 -0
- data/lib/plutonium/ui/table/components/pagy_info.rb +2 -2
- data/lib/plutonium/ui/table/components/pagy_pagination.rb +13 -8
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +101 -0
- data/lib/plutonium/ui/table/components/scopes_bar.rb +2 -2
- data/lib/plutonium/ui/table/components/selection_column.rb +100 -0
- data/lib/plutonium/ui/table/display_theme.rb +6 -6
- data/lib/plutonium/ui/table/resource.rb +93 -52
- data/lib/plutonium/ui/table/theme.rb +28 -15
- data/lib/plutonium/version.rb +1 -1
- data/package.json +2 -2
- data/plutonium.gemspec +5 -4
- data/src/css/components.css +471 -0
- data/src/css/intl_tel_input.css +2 -2
- data/src/css/plutonium.css +2 -0
- data/src/css/tokens.css +149 -0
- data/src/js/controllers/bulk_actions_controller.js +109 -0
- data/src/js/controllers/filter_panel_controller.js +35 -0
- data/src/js/controllers/register_controllers.js +5 -1
- data/src/js/controllers/resource_drop_down_controller.js +25 -1
- data/src/js/controllers/slim_select_controller.js +6 -2
- data/src/js/turbo/turbo_actions.js +1 -1
- metadata +52 -39
- data/.claude/skills/definition-query/SKILL.md +0 -334
- data/docs/concepts/architecture.md +0 -226
- data/docs/concepts/auto-detection.md +0 -254
- data/docs/concepts/index.md +0 -61
- data/docs/concepts/packages-portals.md +0 -304
- data/docs/concepts/resources.md +0 -224
- data/docs/cookbook/blog.md +0 -411
- data/docs/cookbook/index.md +0 -289
- data/docs/cookbook/saas.md +0 -481
- data/docs/public/CLAUDE.md +0 -578
- data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +0 -5
data/docs/cookbook/saas.md
DELETED
|
@@ -1,481 +0,0 @@
|
|
|
1
|
-
# Recipe: SaaS Application
|
|
2
|
-
|
|
3
|
-
Build a multi-tenant SaaS application with organizations, team management, and role-based access.
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
This recipe covers:
|
|
8
|
-
- Multi-tenant data isolation
|
|
9
|
-
- Organization and team management
|
|
10
|
-
- Role-based permissions
|
|
11
|
-
- Invitation system
|
|
12
|
-
- Subscription tiers
|
|
13
|
-
|
|
14
|
-
## Architecture
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
packages/
|
|
18
|
-
├── core/ # Shared: users, organizations
|
|
19
|
-
├── projects/ # Feature: project management
|
|
20
|
-
├── billing/ # Feature: subscriptions
|
|
21
|
-
├── app_portal/ # Main application interface
|
|
22
|
-
└── admin_portal/ # Super admin interface
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
## Core Models
|
|
26
|
-
|
|
27
|
-
### Organization
|
|
28
|
-
|
|
29
|
-
```ruby
|
|
30
|
-
module Core
|
|
31
|
-
class Organization < Core::ResourceRecord
|
|
32
|
-
has_many :memberships, dependent: :destroy
|
|
33
|
-
has_many :users, through: :memberships
|
|
34
|
-
has_many :projects, class_name: 'Projects::Project'
|
|
35
|
-
|
|
36
|
-
validates :name, presence: true
|
|
37
|
-
validates :slug, presence: true, uniqueness: true
|
|
38
|
-
|
|
39
|
-
before_validation :generate_slug, on: :create
|
|
40
|
-
|
|
41
|
-
def owner
|
|
42
|
-
memberships.find_by(role: 'owner')&.user
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
def generate_slug
|
|
48
|
-
self.slug ||= name&.parameterize
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
### Membership
|
|
55
|
-
|
|
56
|
-
```ruby
|
|
57
|
-
module Core
|
|
58
|
-
class Membership < Core::ResourceRecord
|
|
59
|
-
belongs_to :organization
|
|
60
|
-
belongs_to :user
|
|
61
|
-
|
|
62
|
-
ROLES = %w[owner admin member viewer].freeze
|
|
63
|
-
|
|
64
|
-
validates :role, presence: true, inclusion: { in: ROLES }
|
|
65
|
-
validates :user_id, uniqueness: { scope: :organization_id }
|
|
66
|
-
|
|
67
|
-
scope :admins, -> { where(role: %w[owner admin]) }
|
|
68
|
-
|
|
69
|
-
def admin?
|
|
70
|
-
role.in?(%w[owner admin])
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def owner?
|
|
74
|
-
role == 'owner'
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
### User Extensions
|
|
81
|
-
|
|
82
|
-
```ruby
|
|
83
|
-
class User < ApplicationRecord
|
|
84
|
-
has_many :memberships, class_name: 'Core::Membership'
|
|
85
|
-
has_many :organizations, through: :memberships
|
|
86
|
-
|
|
87
|
-
def role_in(organization)
|
|
88
|
-
memberships.find_by(organization: organization)&.role
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def admin_of?(organization)
|
|
92
|
-
memberships.admins.exists?(organization: organization)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def member_of?(organization)
|
|
96
|
-
memberships.exists?(organization: organization)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
## Multi-Tenant Setup
|
|
102
|
-
|
|
103
|
-
### Project Model
|
|
104
|
-
|
|
105
|
-
```ruby
|
|
106
|
-
module Projects
|
|
107
|
-
class Project < Core::ResourceRecord
|
|
108
|
-
belongs_to :organization, class_name: 'Core::Organization'
|
|
109
|
-
belongs_to :creator, class_name: 'User'
|
|
110
|
-
has_many :tasks, dependent: :destroy
|
|
111
|
-
|
|
112
|
-
validates :name, presence: true
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
### Portal Engine
|
|
118
|
-
|
|
119
|
-
```ruby
|
|
120
|
-
module AppPortal
|
|
121
|
-
class Engine < Rails::Engine
|
|
122
|
-
include Plutonium::Portal::Engine
|
|
123
|
-
|
|
124
|
-
config.after_initialize do
|
|
125
|
-
# Scope all data to current organization
|
|
126
|
-
scope_to_entity Core::Organization
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
### Portal Authentication
|
|
133
|
-
|
|
134
|
-
```ruby
|
|
135
|
-
# packages/app_portal/app/controllers/app_portal/concerns/controller.rb
|
|
136
|
-
module AppPortal
|
|
137
|
-
module Concerns
|
|
138
|
-
module Controller
|
|
139
|
-
extend ActiveSupport::Concern
|
|
140
|
-
include Plutonium::Portal::Controller
|
|
141
|
-
include Plutonium::Auth::Rodauth(:user)
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
### Organization Context
|
|
148
|
-
|
|
149
|
-
```ruby
|
|
150
|
-
module AppPortal
|
|
151
|
-
class ResourceController < Plutonium::Portal::ResourceController
|
|
152
|
-
before_action :set_current_organization
|
|
153
|
-
|
|
154
|
-
private
|
|
155
|
-
|
|
156
|
-
def set_current_organization
|
|
157
|
-
@current_organization = current_user.organizations.find_by!(
|
|
158
|
-
slug: params[:org_slug]
|
|
159
|
-
)
|
|
160
|
-
rescue ActiveRecord::RecordNotFound
|
|
161
|
-
redirect_to select_organization_path
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def current_organization
|
|
165
|
-
@current_organization
|
|
166
|
-
end
|
|
167
|
-
helper_method :current_organization
|
|
168
|
-
|
|
169
|
-
def current_membership
|
|
170
|
-
@current_membership ||= current_user.memberships.find_by(
|
|
171
|
-
organization: current_organization
|
|
172
|
-
)
|
|
173
|
-
end
|
|
174
|
-
helper_method :current_membership
|
|
175
|
-
end
|
|
176
|
-
end
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
## Role-Based Policies
|
|
180
|
-
|
|
181
|
-
### Project Policy
|
|
182
|
-
|
|
183
|
-
```ruby
|
|
184
|
-
module Projects
|
|
185
|
-
class ProjectPolicy < Plutonium::Resource::Policy
|
|
186
|
-
def read?
|
|
187
|
-
member?
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def create?
|
|
191
|
-
admin?
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def update?
|
|
195
|
-
admin? || creator?
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def destroy?
|
|
199
|
-
owner?
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def permitted_attributes_for_create
|
|
203
|
-
[:name, :description]
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def permitted_attributes_for_update
|
|
207
|
-
if admin?
|
|
208
|
-
[:name, :description, :status, :archived]
|
|
209
|
-
else
|
|
210
|
-
[:description]
|
|
211
|
-
end
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
# Entity scope handles organization filtering automatically
|
|
215
|
-
# Add role-based filtering here
|
|
216
|
-
def relation_scope(relation)
|
|
217
|
-
if viewer?
|
|
218
|
-
relation.where(archived: false)
|
|
219
|
-
else
|
|
220
|
-
relation
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
private
|
|
225
|
-
|
|
226
|
-
def membership
|
|
227
|
-
@membership ||= context[:membership] ||
|
|
228
|
-
user.memberships.find_by(organization: record.organization)
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def member?
|
|
232
|
-
membership.present?
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def viewer?
|
|
236
|
-
membership&.role == 'viewer'
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
def admin?
|
|
240
|
-
membership&.admin?
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def owner?
|
|
244
|
-
membership&.owner?
|
|
245
|
-
end
|
|
246
|
-
|
|
247
|
-
def creator?
|
|
248
|
-
record.creator_id == user.id
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
## Invitation System
|
|
255
|
-
|
|
256
|
-
### Invitation Model
|
|
257
|
-
|
|
258
|
-
```ruby
|
|
259
|
-
module Core
|
|
260
|
-
class Invitation < Core::ResourceRecord
|
|
261
|
-
belongs_to :organization
|
|
262
|
-
belongs_to :inviter, class_name: 'User'
|
|
263
|
-
belongs_to :user, optional: true # Set when accepted
|
|
264
|
-
|
|
265
|
-
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
266
|
-
validates :role, presence: true, inclusion: { in: Membership::ROLES - ['owner'] }
|
|
267
|
-
validates :token, presence: true, uniqueness: true
|
|
268
|
-
|
|
269
|
-
before_validation :generate_token, on: :create
|
|
270
|
-
|
|
271
|
-
scope :pending, -> { where(accepted_at: nil, expired_at: nil) }
|
|
272
|
-
|
|
273
|
-
def pending?
|
|
274
|
-
accepted_at.nil? && !expired?
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def expired?
|
|
278
|
-
expired_at.present? || created_at < 7.days.ago
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
def accept!(user)
|
|
282
|
-
transaction do
|
|
283
|
-
update!(accepted_at: Time.current, user: user)
|
|
284
|
-
organization.memberships.create!(user: user, role: role)
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
private
|
|
289
|
-
|
|
290
|
-
def generate_token
|
|
291
|
-
self.token ||= SecureRandom.urlsafe_base64(32)
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
end
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
### Invite User Interaction
|
|
298
|
-
|
|
299
|
-
```ruby
|
|
300
|
-
module Core
|
|
301
|
-
class InviteUser < Plutonium::Interaction::Base
|
|
302
|
-
presents model_class: Organization
|
|
303
|
-
presents label: "Invite Team Member"
|
|
304
|
-
|
|
305
|
-
attribute :email, :string
|
|
306
|
-
attribute :role, :string
|
|
307
|
-
|
|
308
|
-
validates :email, presence: true
|
|
309
|
-
validates :role, presence: true, inclusion: { in: Membership::ROLES - ['owner'] }
|
|
310
|
-
validate :not_already_member
|
|
311
|
-
|
|
312
|
-
def execute
|
|
313
|
-
invitation = resource.invitations.create!(
|
|
314
|
-
email: email,
|
|
315
|
-
role: role,
|
|
316
|
-
inviter: context[:user]
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
InvitationMailer.invite(invitation).deliver_later
|
|
320
|
-
|
|
321
|
-
succeed(resource)
|
|
322
|
-
.with_message("Invitation sent to #{email}")
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
private
|
|
326
|
-
|
|
327
|
-
def not_already_member
|
|
328
|
-
if resource.users.exists?(email: email)
|
|
329
|
-
errors.add(:email, "is already a member")
|
|
330
|
-
end
|
|
331
|
-
end
|
|
332
|
-
end
|
|
333
|
-
end
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
## Organization Switcher
|
|
337
|
-
|
|
338
|
-
### Routes
|
|
339
|
-
|
|
340
|
-
```ruby
|
|
341
|
-
# config/routes.rb
|
|
342
|
-
Rails.application.routes.draw do
|
|
343
|
-
get '/org/select', to: 'organizations#select', as: :select_organization
|
|
344
|
-
post '/org/switch/:id', to: 'organizations#switch', as: :switch_organization
|
|
345
|
-
|
|
346
|
-
scope '/:org_slug' do
|
|
347
|
-
mount AppPortal::Engine, at: '/app'
|
|
348
|
-
end
|
|
349
|
-
end
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
### Controller
|
|
353
|
-
|
|
354
|
-
```ruby
|
|
355
|
-
class OrganizationsController < ApplicationController
|
|
356
|
-
before_action :authenticate_user!
|
|
357
|
-
|
|
358
|
-
def select
|
|
359
|
-
@organizations = current_user.organizations
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
def switch
|
|
363
|
-
organization = current_user.organizations.find(params[:id])
|
|
364
|
-
session[:current_organization_id] = organization.id
|
|
365
|
-
redirect_to app_root_path(org_slug: organization.slug)
|
|
366
|
-
end
|
|
367
|
-
end
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
## Definitions
|
|
371
|
-
|
|
372
|
-
### Organization Definition
|
|
373
|
-
|
|
374
|
-
```ruby
|
|
375
|
-
module Core
|
|
376
|
-
class OrganizationDefinition < Plutonium::Resource::Definition
|
|
377
|
-
field :name
|
|
378
|
-
field :slug, readonly: true
|
|
379
|
-
field :logo, as: :file, accept: "image/*"
|
|
380
|
-
|
|
381
|
-
column :name, sortable: true
|
|
382
|
-
column :members_count do |org|
|
|
383
|
-
org.memberships.count
|
|
384
|
-
end
|
|
385
|
-
column :created_at
|
|
386
|
-
|
|
387
|
-
association :memberships, fields: [:user, :role, :created_at]
|
|
388
|
-
|
|
389
|
-
action :invite, interaction: InviteUser
|
|
390
|
-
end
|
|
391
|
-
end
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
### Membership Definition
|
|
395
|
-
|
|
396
|
-
```ruby
|
|
397
|
-
module Core
|
|
398
|
-
class MembershipDefinition < Plutonium::Resource::Definition
|
|
399
|
-
field :user
|
|
400
|
-
field :role, as: :select, collection: Membership::ROLES
|
|
401
|
-
|
|
402
|
-
column :user
|
|
403
|
-
column :role
|
|
404
|
-
column :created_at
|
|
405
|
-
|
|
406
|
-
action :change_role, interaction: ChangeMemberRole
|
|
407
|
-
action :remove, interaction: RemoveMember, color: :danger
|
|
408
|
-
end
|
|
409
|
-
end
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
## Subscription Integration
|
|
413
|
-
|
|
414
|
-
### Plan Model
|
|
415
|
-
|
|
416
|
-
```ruby
|
|
417
|
-
module Billing
|
|
418
|
-
class Plan < Core::ResourceRecord
|
|
419
|
-
has_many :subscriptions
|
|
420
|
-
|
|
421
|
-
validates :name, presence: true
|
|
422
|
-
validates :price_cents, presence: true
|
|
423
|
-
validates :max_projects, presence: true
|
|
424
|
-
validates :max_members, presence: true
|
|
425
|
-
|
|
426
|
-
has_cents :price
|
|
427
|
-
end
|
|
428
|
-
end
|
|
429
|
-
```
|
|
430
|
-
|
|
431
|
-
### Subscription Checks
|
|
432
|
-
|
|
433
|
-
```ruby
|
|
434
|
-
module Core
|
|
435
|
-
class Organization < Core::ResourceRecord
|
|
436
|
-
has_one :subscription, class_name: 'Billing::Subscription'
|
|
437
|
-
|
|
438
|
-
def can_add_project?
|
|
439
|
-
projects.count < subscription.plan.max_projects
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
def can_add_member?
|
|
443
|
-
memberships.count < subscription.plan.max_members
|
|
444
|
-
end
|
|
445
|
-
end
|
|
446
|
-
end
|
|
447
|
-
|
|
448
|
-
# In policy
|
|
449
|
-
def create?
|
|
450
|
-
admin? && record.organization.can_add_project?
|
|
451
|
-
end
|
|
452
|
-
```
|
|
453
|
-
|
|
454
|
-
## Usage
|
|
455
|
-
|
|
456
|
-
```bash
|
|
457
|
-
# Create packages
|
|
458
|
-
rails generate pu:pkg:package core
|
|
459
|
-
rails generate pu:pkg:package projects
|
|
460
|
-
rails generate pu:pkg:package billing
|
|
461
|
-
rails generate pu:pkg:portal app
|
|
462
|
-
|
|
463
|
-
# Generate models
|
|
464
|
-
rails generate pu:res:scaffold Organization name:string slug:string --dest=core
|
|
465
|
-
rails generate pu:res:scaffold Membership role:string organization:belongs_to user:belongs_to --dest=core
|
|
466
|
-
rails generate pu:res:scaffold Project name:string organization:belongs_to --dest=projects
|
|
467
|
-
|
|
468
|
-
# Connect to portal
|
|
469
|
-
rails generate pu:res:conn Projects::Project --dest=app_portal
|
|
470
|
-
|
|
471
|
-
rails db:migrate
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
## Security Checklist
|
|
475
|
-
|
|
476
|
-
- [ ] All models scoped to organization
|
|
477
|
-
- [ ] Policies check membership
|
|
478
|
-
- [ ] Invitations expire
|
|
479
|
-
- [ ] Role changes audited
|
|
480
|
-
- [ ] Owner cannot be removed
|
|
481
|
-
- [ ] Data isolated in queries
|