rivet_cms 0.1.2.pre → 0.3.1

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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -0
  3. data/COPYING +674 -0
  4. data/COPYING.LESSER +165 -0
  5. data/README.md +173 -10
  6. data/app/assets/builds/rivet_cms.css +2 -2
  7. data/app/assets/builds/rivet_cms.js +395 -9505
  8. data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
  9. data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
  10. data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
  11. data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
  12. data/app/controllers/rivet_cms/application_controller.rb +220 -2
  13. data/app/controllers/rivet_cms/auth_controller.rb +28 -0
  14. data/app/controllers/rivet_cms/components_controller.rb +93 -2
  15. data/app/controllers/rivet_cms/content_controller.rb +105 -0
  16. data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
  17. data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
  18. data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
  19. data/app/controllers/rivet_cms/documents_controller.rb +204 -0
  20. data/app/controllers/rivet_cms/fields_controller.rb +77 -74
  21. data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
  22. data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
  23. data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
  24. data/app/controllers/rivet_cms/setup_controller.rb +60 -0
  25. data/app/controllers/rivet_cms/trash_controller.rb +68 -0
  26. data/app/controllers/rivet_cms/users_controller.rb +117 -0
  27. data/app/helpers/rivet_cms/application_helper.rb +13 -3
  28. data/app/helpers/rivet_cms/components_helper.rb +4 -0
  29. data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
  30. data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
  31. data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
  32. data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
  33. data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
  34. data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
  35. data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
  36. data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
  37. data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
  38. data/app/models/rivet_cms/api_token.rb +64 -0
  39. data/app/models/rivet_cms/category.rb +20 -0
  40. data/app/models/rivet_cms/component.rb +10 -0
  41. data/app/models/rivet_cms/component_instance.rb +27 -0
  42. data/app/models/rivet_cms/content_type.rb +36 -23
  43. data/app/models/rivet_cms/content_value.rb +19 -0
  44. data/app/models/rivet_cms/current.rb +6 -0
  45. data/app/models/rivet_cms/document.rb +71 -0
  46. data/app/models/rivet_cms/document_revision.rb +128 -0
  47. data/app/models/rivet_cms/entry.rb +73 -0
  48. data/app/models/rivet_cms/entry_collection.rb +41 -0
  49. data/app/models/rivet_cms/field.rb +207 -61
  50. data/app/models/rivet_cms/media_asset.rb +109 -0
  51. data/app/models/rivet_cms/organization.rb +46 -0
  52. data/app/models/rivet_cms/relation.rb +23 -0
  53. data/app/models/rivet_cms/user.rb +61 -0
  54. data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
  55. data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
  56. data/app/services/rivet_cms/content_query.rb +158 -0
  57. data/app/services/rivet_cms/content_validator.rb +150 -0
  58. data/app/services/rivet_cms/draft_writer.rb +74 -0
  59. data/app/services/rivet_cms/open_api_generator.rb +173 -221
  60. data/app/services/rivet_cms/revision_pruner.rb +64 -0
  61. data/app/services/rivet_cms/webhooks.rb +61 -0
  62. data/app/views/layouts/rivet_cms/application.html.erb +35 -42
  63. data/config/routes.rb +68 -45
  64. data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
  65. data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
  66. data/db/migrate/20251121020625_create_rivet_cms_components.rb +17 -0
  67. data/db/migrate/20251121040115_create_rivet_cms_categories.rb +17 -0
  68. data/db/migrate/20260119185946_create_content_system_tables.rb +97 -0
  69. data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
  70. data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
  71. data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
  72. data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
  73. data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
  74. data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
  75. data/db/seeds/README.md +70 -0
  76. data/db/seeds/templates/blog.rb +39 -0
  77. data/db/seeds/templates/events.rb +12 -0
  78. data/db/seeds/templates/faq.rb +9 -0
  79. data/db/seeds/templates/pages.rb +35 -0
  80. data/db/seeds/templates/site_settings.rb +22 -0
  81. data/db/seeds/templates/team.rb +11 -0
  82. data/db/seeds/templates/testimonials.rb +11 -0
  83. data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
  84. data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
  85. data/lib/rivet_cms/access_check.rb +12 -0
  86. data/lib/rivet_cms/audit.rb +43 -0
  87. data/lib/rivet_cms/engine.rb +28 -22
  88. data/lib/rivet_cms/hooks.rb +106 -0
  89. data/lib/rivet_cms/navigation.rb +111 -0
  90. data/lib/rivet_cms/safe_pattern.rb +40 -0
  91. data/lib/rivet_cms/seeds.rb +264 -0
  92. data/lib/rivet_cms/version.rb +1 -1
  93. data/lib/rivet_cms.rb +289 -32
  94. data/lib/tasks/rivet_cms_tasks.rake +18 -4
  95. metadata +138 -98
  96. data/MIT-LICENSE +0 -20
  97. data/app/assets/builds/rivet_cms.js.map +0 -7
  98. data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
  99. data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
  100. data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
  101. data/app/helpers/rivet_cms/flash_helper.rb +0 -37
  102. data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
  103. data/app/javascript/controllers/content_type_form_controller.js +0 -53
  104. data/app/javascript/controllers/field_layout_controller.js +0 -709
  105. data/app/javascript/rivet_cms.js +0 -29
  106. data/app/models/rivet_cms/content.rb +0 -4
  107. data/app/models/rivet_cms/field_values/base.rb +0 -11
  108. data/app/models/rivet_cms/field_values/boolean.rb +0 -4
  109. data/app/models/rivet_cms/field_values/integer.rb +0 -4
  110. data/app/models/rivet_cms/field_values/string.rb +0 -4
  111. data/app/models/rivet_cms/field_values/text.rb +0 -4
  112. data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
  113. data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
  114. data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
  115. data/app/views/rivet_cms/content_types/index.html.erb +0 -151
  116. data/app/views/rivet_cms/content_types/new.html.erb +0 -19
  117. data/app/views/rivet_cms/content_types/show.html.erb +0 -147
  118. data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
  119. data/app/views/rivet_cms/fields/_form.html.erb +0 -111
  120. data/app/views/rivet_cms/fields/edit.html.erb +0 -25
  121. data/app/views/rivet_cms/fields/index.html.erb +0 -126
  122. data/app/views/rivet_cms/fields/new.html.erb +0 -25
  123. data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
  124. data/config/i18n-tasks.yml +0 -178
  125. data/config/locales/en.yml +0 -14
  126. data/db/migrate/20250317194359_create_core_tables.rb +0 -90
  127. data/lib/rivet_cms/routes.rb +0 -49
@@ -1,10 +1,60 @@
1
1
  module RivetCms
2
2
  class DashboardController < ApplicationController
3
- def index
4
- @content_types = ContentType.order(:name)
5
- @recent_contents = Content.order(updated_at: :desc).limit(5)
6
- @components = Component.all
7
- @content_type = ContentType.new
3
+ include InertiaProps
4
+
5
+ # The dashboard stays reachable by any authenticated user, but every
6
+ # section's data is filtered through the same can? checks that gate the
7
+ # section's own pages, so it never leaks what those pages would refuse.
8
+ def show
9
+ read_content = can?(:read, :content)
10
+ read_schema = can?(:read, :schema)
11
+
12
+ organization = Current.organization
13
+ content_types = read_content || read_schema ? organization.content_types.order(:name).to_a : []
14
+ # Type cards link to entry lists, so they filter through whichever
15
+ # record-phase read the user holds for that surface
16
+ content_types = content_types.select { |ct|
17
+ read_content ? can?(:read, :content, record: ct) : can?(:read, :schema, record: ct)
18
+ }
19
+ documents = Document.where(organization: organization).in_visible_types
20
+ entry_counts = read_content ? documents.group(:content_type_id).count : {}
21
+
22
+ stats = {}
23
+ stats[:entries] = documents.count if read_content
24
+ if read_schema
25
+ stats[:content_types] = content_types.size
26
+ stats[:components] = organization.components.count
27
+ end
28
+ read_api = can?(:read, :api)
29
+ stats[:media_assets] = organization.media_assets.count if can?(:read, :media)
30
+ stats[:api_tokens] = organization.api_tokens.count if read_api
31
+
32
+ render inertia: "Dashboard/Show", props: {
33
+ stats: stats,
34
+ has_fields: read_schema && Field.where(content_type_id: content_types.map(&:id)).exists?,
35
+ recent_documents: recent_documents(documents, read_content),
36
+ content_types: content_types.map { |content_type|
37
+ content_type_ref_props(content_type).merge(entry_count: entry_counts.fetch(content_type.id, 0))
38
+ },
39
+ api: read_api && read_schema ? { base_path: File.join(root_path, "api") } : nil,
40
+ permissions: {
41
+ write_content: can?(:write, :content),
42
+ write_schema: can?(:write, :schema)
43
+ }
44
+ }
45
+ end
46
+
47
+ private
48
+
49
+ def recent_documents(documents, read_content)
50
+ return [] unless read_content
51
+
52
+ permitted_documents(documents.includes(:content_type).order(updated_at: :desc).limit(8)).map { |document|
53
+ document_props(document).merge(
54
+ content_type_name: document.content_type.name,
55
+ updated_at: document.updated_at.iso8601
56
+ )
57
+ }
8
58
  end
9
59
  end
10
60
  end
@@ -0,0 +1,204 @@
1
+ module RivetCms
2
+ class DocumentsController < ApplicationController
3
+ include InertiaProps
4
+
5
+ before_action -> { authorize! :read, :content }, only: [ :index, :edit, :new, :trash ]
6
+ before_action -> { authorize! :write, :content }, except: [ :index, :edit, :trash, :publish, :destroy, :restore, :purge ]
7
+ # Restore mirrors trash: whoever can put an entry in can take it out,
8
+ # since restoring flips it back onto the delivery API.
9
+ before_action -> { authorize! :delete, :content }, only: [ :restore ]
10
+ before_action -> { authorize! :delete, :content }, only: [ :purge ]
11
+ before_action -> { authorize! :publish, :content }, only: [ :publish ]
12
+ before_action -> { authorize! :delete, :content }, only: [ :destroy ]
13
+ before_action :set_content_type
14
+ before_action :set_document, only: [ :edit, :update, :destroy, :publish ]
15
+ before_action :set_trashed_document, only: [ :restore, :purge ]
16
+ # Record layer: the same gates again, now about the loaded record, so a
17
+ # policy can grant per-type or per-entry access. The recordless gates
18
+ # above stay as fail-fast checks that run before anything is loaded.
19
+ # Member actions check the type as well as the entry, so denying a type
20
+ # denies its entries even on a direct URL, with the verb matched:
21
+ # (action, :content, record: type) means "may <action> this type's content".
22
+ before_action -> { authorize! :read, :content, record: @content_type }, only: [ :index, :new, :trash, :edit ]
23
+ before_action -> { authorize! :write, :content, record: @content_type }, only: [ :create, :update ]
24
+ before_action -> { authorize! :publish, :content, record: @content_type }, only: [ :publish ]
25
+ before_action -> { authorize! :delete, :content, record: @content_type }, only: [ :destroy, :restore, :purge ]
26
+ before_action -> { authorize! :read, :content, record: @document }, only: [ :edit ]
27
+ before_action -> { authorize! :write, :content, record: @document }, only: [ :update ]
28
+ before_action -> { authorize! :publish, :content, record: @document }, only: [ :publish ]
29
+ before_action -> { authorize! :delete, :content, record: @document }, only: [ :destroy, :restore, :purge ]
30
+
31
+ def index
32
+ documents = @content_type.documents.recent
33
+ documents = documents.search(params[:q]) if params[:q].present?
34
+ page = documents.includes(:draft_revision).page(params[:page]).per(25)
35
+ visible = permitted(page, :read, :content)
36
+
37
+ titles = document_titles(visible)
38
+
39
+ render inertia: "Documents/Index", props: {
40
+ content_type: content_type_props(@content_type),
41
+ q: params[:q].presence,
42
+ trashed_count: @content_type.documents.with_discarded.discarded.count,
43
+ trash_path: trash_content_type_documents_path(@content_type),
44
+ documents: visible.map { |document| document_list_props(document, titles) },
45
+ pagination: { page: page.current_page, total_pages: page.total_pages }
46
+ }
47
+ end
48
+
49
+ def new
50
+ render inertia: "Documents/Edit", props: editor_props(nil)
51
+ end
52
+
53
+ def create
54
+ document = @content_type.documents.new(slug: params[:slug])
55
+ document.save!
56
+ draft = document.revisions.create!(state: :draft, **author_attributes)
57
+ document.update!(draft_revision: draft)
58
+ writer = DraftWriter.new(draft).tap { |w| w.write(values_param) }
59
+
60
+ audit "entry.created", document
61
+ redirect_to edit_content_type_document_path(@content_type, document), notice: write_notice(writer, "Draft saved")
62
+ rescue ActiveRecord::RecordInvalid => e
63
+ redirect_to new_content_type_document_path(@content_type), inertia: { errors: e.record.errors }
64
+ end
65
+
66
+ def edit
67
+ render inertia: "Documents/Edit", props: editor_props(@document)
68
+ end
69
+
70
+ def update
71
+ @document.draft_revision.update!(author_attributes)
72
+ writer = DraftWriter.new(@document.draft_revision).tap { |w| w.write(values_param) }
73
+ audit "entry.updated", @document
74
+ redirect_to edit_content_type_document_path(@content_type, @document), notice: write_notice(writer, "Draft saved")
75
+ end
76
+
77
+ # Publishes what's on screen: the submitted values are written to the
78
+ # draft first, so unsaved edits are validated instead of the stale draft.
79
+ def publish
80
+ draft = @document.draft_revision
81
+ writer = nil
82
+ if params.key?(:values)
83
+ draft.update!(author_attributes)
84
+ writer = DraftWriter.new(draft).tap { |w| w.write(values_param) }
85
+ end
86
+ # The snapshot records who published, which is not always the last editor
87
+ draft.publish!(publisher: author_attributes[:author], publisher_name: author_attributes[:author_name])
88
+ audit "entry.published", @document
89
+ redirect_to edit_content_type_document_path(@content_type, @document), notice: write_notice(writer, "Published")
90
+ rescue ContentInvalidError => e
91
+ redirect_to edit_content_type_document_path(@content_type, @document),
92
+ inertia: { errors: e.errors.group_by(&:field_key).transform_values { |group| group.map(&:message) } }
93
+ end
94
+
95
+ def destroy
96
+ @document.discard!
97
+ audit "entry.trashed", @document
98
+ # Back to whichever list the delete came from, except the entry's own
99
+ # editor, whose URL just died with the trashing
100
+ dead = edit_content_type_document_path(@content_type, @document)
101
+ redirect_after_trash(dead, content_type_documents_path(@content_type),
102
+ notice: "#{@document.slug} was moved to the trash")
103
+ end
104
+
105
+ def trash
106
+ documents = permitted(@content_type.documents.with_discarded.discarded.order(:deleted_at), :read, :content)
107
+ titles = document_titles(documents)
108
+
109
+ render inertia: "Documents/Trash", props: {
110
+ content_type: content_type_props(@content_type),
111
+ documents: documents.map { |document|
112
+ document_list_props(document, titles).merge(
113
+ trashed_at: document.deleted_at.iso8601,
114
+ paths: {
115
+ restore: restore_content_type_document_path(@content_type, document),
116
+ purge: purge_content_type_document_path(@content_type, document)
117
+ }
118
+ )
119
+ }
120
+ }
121
+ end
122
+
123
+ def restore
124
+ @document.undiscard!
125
+ audit "entry.restored", @document
126
+ redirect_to edit_content_type_document_path(@content_type, @document),
127
+ notice: "#{@document.slug} was restored"
128
+ end
129
+
130
+ # Permanently destroys one entry and its revisions. Smaller blast radius
131
+ # than purging a type, so a plain confirmation rather than a typed name.
132
+ def purge
133
+ slug = @document.slug
134
+ Document.transaction do
135
+ Relation.where(target_document_id: @document.id).delete_all
136
+ @document.destroy!
137
+ end
138
+ audit "entry.purged", @document
139
+ # Back to whichever trash (global or scoped) the purge came from
140
+ redirect_back fallback_location: trash_content_type_documents_path(@content_type),
141
+ allow_other_host: false, status: :see_other,
142
+ notice: "#{slug} was permanently deleted"
143
+ rescue ActiveRecord::ActiveRecordError => error
144
+ Rails.logger&.error("[RivetCms] purge failed for document #{@document.id}: #{error.class}")
145
+ redirect_back fallback_location: trash_content_type_documents_path(@content_type),
146
+ allow_other_host: false, status: :see_other,
147
+ alert: "#{slug} could not be deleted; nothing was removed"
148
+ end
149
+
150
+ private
151
+
152
+ # Dropping a reference is not reversible by restoring the target, so every
153
+ # path that writes values says so: create, save, and publish alike.
154
+ def write_notice(writer, base)
155
+ dropped = writer&.dropped_references.to_i
156
+ return base if dropped.zero?
157
+
158
+ detail = dropped == 1 ? "1 reference was removed because the entry it pointed to is in the trash or was deleted." \
159
+ : "#{dropped} references were removed because the entries they pointed to are in the trash or were deleted."
160
+ "#{base}. #{detail}"
161
+ end
162
+
163
+ def set_content_type
164
+ @content_type = Current.organization.content_types.find(params[:content_type_id])
165
+ end
166
+
167
+ def set_document
168
+ @document = @content_type.documents.find(params[:id])
169
+ end
170
+
171
+ def set_trashed_document
172
+ @document = @content_type.documents.with_discarded.discarded.find(params[:id])
173
+ end
174
+
175
+ def editor_props(document)
176
+ {
177
+ content_type: content_type_props(@content_type),
178
+ fields: @content_type.fields.kept.ordered.map { |field| entry_field_props(field) },
179
+ document: document ? document_props(document) : nil,
180
+ values: document ? draft_values(document.draft_revision) : {},
181
+ reference_options: reference_options,
182
+ form_paths: { index: content_type_documents_path(@content_type), create: content_type_documents_path(@content_type) }
183
+ }
184
+ end
185
+
186
+ def reference_options
187
+ permitted_documents(Document.where(organization: Current.organization).in_visible_types.recent.includes(:content_type))
188
+ .map { |document| { id: document.id, slug: document.slug, content_type: document.content_type.name } }
189
+ end
190
+
191
+ def values_param
192
+ params.fetch(:values, {}).permit!.to_h
193
+ end
194
+
195
+ # Content is always attributed. With a signed-in user it's their name;
196
+ # without one (unconfigured/programmatic) it falls back to "System".
197
+ def author_attributes
198
+ @author_attributes ||= begin
199
+ user = Current.user
200
+ { author: user, author_name: user ? RivetCms.user_name.call(user).presence || "System" : "System" }
201
+ end
202
+ end
203
+ end
204
+ end
@@ -1,109 +1,112 @@
1
1
  module RivetCms
2
2
  class FieldsController < ApplicationController
3
- before_action :set_content_type
4
- before_action :set_field, only: [:edit, :update, :destroy, :update_width]
3
+ before_action -> { authorize! :delete, :schema }, only: [ :destroy ]
4
+ before_action -> { authorize! :write, :schema }, except: [ :destroy ]
5
+ before_action :set_owner
6
+ before_action :set_field, only: [ :update, :destroy, :toggle_width, :unpair, :pair ]
7
+ # Record layer: every field action touches its owner's schema, so the
8
+ # owner is checked for all of them, verb-matched like entries: writes
9
+ # need :write on the owner, destroy needs :delete. Denying a type or
10
+ # component denies its fields even on a direct URL.
11
+ before_action -> { authorize! :write, :schema, record: @owner }, except: [ :destroy ]
12
+ before_action -> { authorize! :delete, :schema, record: @owner }, only: [ :destroy ]
13
+ before_action -> { authorize! :write, :schema, record: @field }, only: [ :update, :toggle_width, :unpair, :pair ]
14
+ before_action -> { authorize! :delete, :schema, record: @field }, only: [ :destroy ]
5
15
 
6
- # GET /content_types/:content_type_id/fields
7
- def index
8
- @fields = @content_type.fields.includes(:component).order(:position)
9
- @field = @content_type.fields.new
10
- end
11
-
12
- # GET /content_types/:content_type_id/fields/new
13
- def new
14
- @field = @content_type.fields.new
15
- end
16
-
17
- # POST /content_types/:content_type_id/fields
18
16
  def create
19
- @field = @content_type.fields.new(field_params)
17
+ field = @owner.fields.build(field_params)
18
+ field.organization = @owner.organization
20
19
 
21
- if @field.save
22
- redirect_to content_type_fields_path(@content_type), notice: "Field was successfully created."
20
+ if field.save
21
+ audit "field.created", field
22
+ redirect_to owner_path, notice: "Field created"
23
23
  else
24
- respond_to do |format|
25
- format.html { render :new, status: :unprocessable_entity }
26
- format.turbo_stream {
27
- render turbo_stream: turbo_stream.update("field_form",
28
- partial: "form",
29
- locals: { content_type: @content_type, field: @field }
30
- )
31
- }
32
- end
24
+ redirect_to owner_path, inertia: { errors: field.errors }
33
25
  end
34
26
  end
35
27
 
36
- # GET /content_types/:content_type_id/fields/:id/edit
37
- def edit
38
- end
39
-
40
- # PATCH/PUT /content_types/:content_type_id/fields/:id
41
28
  def update
42
29
  if @field.update(field_params)
43
- redirect_to content_type_fields_path(@content_type), notice: "Field was successfully updated."
30
+ audit "field.updated", @field
31
+ redirect_to owner_path, notice: "Field updated"
44
32
  else
45
- respond_to do |format|
46
- format.html { render :edit, status: :unprocessable_entity }
47
- format.turbo_stream {
48
- render turbo_stream: turbo_stream.update("field_form",
49
- partial: "form",
50
- locals: { content_type: @content_type, field: @field }
51
- )
52
- }
53
- end
33
+ redirect_to owner_path, inertia: { errors: @field.errors }
54
34
  end
55
35
  end
56
36
 
57
- # DELETE /content_types/:content_type_id/fields/:id
58
37
  def destroy
59
- @field.destroy
60
- redirect_to content_type_fields_path(@content_type), notice: "Field was successfully deleted."
38
+ @field.discard
39
+ audit "field.removed", @field
40
+ redirect_to owner_path, notice: "Field removed"
61
41
  end
62
42
 
63
- # POST /content_types/:content_type_id/fields/update_positions
64
- def update_positions
65
- if params[:positions].present?
66
- ActiveRecord::Base.transaction do
67
- params[:positions].each do |position_data|
68
- field = @content_type.fields.find_by_prefix_id(position_data[:id])
69
- if field
70
- field.update!(
71
- position: position_data[:position],
72
- row_group: position_data[:row_group]
73
- )
74
- end
75
- end
43
+ def toggle_width
44
+ new_width = @field.width_full? ? "half" : "full"
45
+
46
+ @field.update!(width: new_width)
47
+
48
+ # If changed to full width, ensure it's on its own row
49
+ if new_width == "full"
50
+ other_fields_on_row = @owner.fields.kept.where(row: @field.row).where.not(id: @field.id)
51
+ if other_fields_on_row.exists?
52
+ @field.move_to_own_row!
76
53
  end
77
-
78
- render json: { success: true }
79
- else
80
- render json: { error: "No positions data provided" }, status: :bad_request
81
54
  end
55
+
56
+ audit "field.updated", @field, change: "layout"
57
+ redirect_to owner_path
82
58
  end
83
59
 
84
- # PATCH /content_types/:content_type_id/fields/:id/update_width
85
- def update_width
86
- width_value = params[:width]
87
- if width_value.present? && RivetCms::Field::WIDTHS.include?(width_value)
88
- result = @field.update_columns(width: width_value, row_group: nil)
89
- render json: { success: true, field: { id: @field.to_param, width: @field.width } }
90
- else
91
- render json: { error: "Invalid width value" }, status: :unprocessable_entity
60
+ def unpair
61
+ @field.unpair!
62
+ audit "field.updated", @field, change: "layout"
63
+ redirect_to owner_path
64
+ end
65
+
66
+ def pair
67
+ other_field = @owner.fields.find(params[:pair_with])
68
+ # Pairing mutates both fields, so both must pass the record phase
69
+ authorize! :write, :schema, record: other_field
70
+
71
+ # Both fields must be half-width and not already paired
72
+ if @field.width_half? && other_field.width_half? && !@field.paired? && !other_field.paired?
73
+ @field.pair_with!(other_field)
74
+ audit "field.updated", @field, change: "layout"
92
75
  end
76
+
77
+ redirect_to owner_path
78
+ end
79
+
80
+ def update_layout
81
+ # The bulk update touches every referenced field, so each must pass the
82
+ # record phase, not just the owner
83
+ moved = @owner.fields.where(id: Array(params[:rows]).flatten)
84
+ moved.each { |field| authorize! :write, :schema, record: field }
85
+ @owner.fields.update_layout!(params[:rows])
86
+ audit "schema.layout_updated", @owner
87
+ redirect_to owner_path
93
88
  end
94
89
 
95
90
  private
96
91
 
97
- def set_content_type
98
- @content_type = ContentType.find(params[:content_type_id])
92
+ def set_owner
93
+ @owner = if params[:component_id]
94
+ Current.organization.components.find(params[:component_id])
95
+ else
96
+ Current.organization.content_types.find(params[:content_type_id])
97
+ end
98
+ end
99
+
100
+ def owner_path
101
+ @owner.is_a?(Component) ? component_path(@owner) : content_type_path(@owner)
99
102
  end
100
103
 
101
104
  def set_field
102
- @field = @content_type.fields.find_by_prefix_id(params[:id])
105
+ @field = @owner.fields.find(params[:id])
103
106
  end
104
107
 
105
108
  def field_params
106
- params.require(:field).permit(:name, :field_type, :required, :component_id, :description, :width, options: {})
109
+ params.require(:field).permit(:key, :label, :field_type, :description, :required, :width, :min_items, :max_items, config: {})
107
110
  end
108
111
  end
109
- end
112
+ end
@@ -0,0 +1,36 @@
1
+ module RivetCms
2
+ # Set-password links: signed, expiring, invalidated the moment a password
3
+ # is set (the token embeds the password salt). No mailers involved; the
4
+ # admin copies the link and delivers it however they like.
5
+ class InvitationsController < AuthController
6
+ before_action :set_user_from_token
7
+
8
+ def show
9
+ render inertia: "Auth/SetPassword", props: { name: @user.name, submit_path: invitation_path(params[:token]) }
10
+ end
11
+
12
+ def update
13
+ if params[:password].blank?
14
+ # An empty string is ignored by has_secure_password: update would
15
+ # "succeed" without setting anything and sign in a pending user
16
+ return redirect_to invitation_path(params[:token]), inertia: { errors: { password: [ "can't be blank" ] } }
17
+ end
18
+
19
+ if @user.update(password: params[:password])
20
+ sign_in(@user)
21
+ redirect_to root_path, notice: "You're in. Welcome to RivetCMS"
22
+ else
23
+ redirect_to invitation_path(params[:token]), inertia: { errors: @user.errors }
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def set_user_from_token
30
+ @user = User.find_by_token_for(:password_setup, params[:token])
31
+ return if @user&.active? && @user.organization == Current.organization
32
+
33
+ redirect_to login_path, alert: "That link is no longer valid. Ask an admin for a new one."
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,76 @@
1
+ module RivetCms
2
+ class MediaAssetsController < ApplicationController
3
+ include InertiaProps
4
+
5
+ before_action -> { authorize! :read, :media }, only: [ :index ]
6
+ before_action -> { authorize! :delete, :media }, only: [ :destroy ]
7
+ before_action -> { authorize! :write, :media }, except: [ :index, :destroy ]
8
+
9
+ def index
10
+ scope = media_assets.recent
11
+ scope = scope.search(params[:q]) if params[:q].present?
12
+ scope = scope.where(kind: params[:kind]) if MediaAsset.kinds.key?(params[:kind])
13
+ page = scope.page(params[:page]).per(48)
14
+ assets = permitted(page, :read, :media).map { |asset| media_asset_json(asset) }
15
+
16
+ respond_to do |format|
17
+ format.json { render json: assets }
18
+ format.html do
19
+ render inertia: "Media/Index", props: {
20
+ assets: assets,
21
+ q: params[:q].presence,
22
+ kind: params[:kind].presence,
23
+ kinds: media_assets.distinct.order(:kind).pluck(:kind),
24
+ pagination: { page: page.current_page, total_pages: page.total_pages }
25
+ }
26
+ end
27
+ end
28
+ end
29
+
30
+ def create
31
+ unless params[:file].respond_to?(:tempfile)
32
+ return render json: { errors: [ "file is required" ] }, status: :unprocessable_entity
33
+ end
34
+
35
+ asset = MediaAsset.new(file: params[:file])
36
+
37
+ if asset.save
38
+ audit "media.uploaded", asset
39
+ render json: media_asset_json(asset), status: :created
40
+ else
41
+ render json: { errors: asset.errors.full_messages }, status: :unprocessable_entity
42
+ end
43
+ end
44
+
45
+ def update
46
+ asset = media_assets.find(params[:id])
47
+ authorize! :write, :media, record: asset
48
+ if asset.update(params.permit(:title, :alt, :description))
49
+ audit "media.updated", asset
50
+ render json: media_asset_json(asset)
51
+ else
52
+ render json: { errors: asset.errors.full_messages }, status: :unprocessable_entity
53
+ end
54
+ end
55
+
56
+ def destroy
57
+ asset = media_assets.find(params[:id])
58
+ authorize! :delete, :media, record: asset
59
+ if asset.embedded_in_content?
60
+ return redirect_to media_assets_path, alert: "Media is embedded in content and cannot be deleted"
61
+ end
62
+
63
+ asset.destroy
64
+ audit "media.deleted", asset
65
+ redirect_to media_assets_path, notice: "Media deleted"
66
+ rescue ActiveRecord::InvalidForeignKey
67
+ redirect_to media_assets_path, alert: "Media is in use by content and cannot be deleted"
68
+ end
69
+
70
+ private
71
+
72
+ def media_assets
73
+ Current.organization.media_assets
74
+ end
75
+ end
76
+ end