rivet_cms 0.1.2.pre → 0.3.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/CHANGELOG.md +53 -0
- data/COPYING +674 -0
- data/COPYING.LESSER +165 -0
- data/README.md +173 -10
- data/app/assets/builds/rivet_cms.css +2 -2
- data/app/assets/builds/rivet_cms.js +395 -9505
- data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
- data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
- data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
- data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
- data/app/controllers/rivet_cms/application_controller.rb +220 -2
- data/app/controllers/rivet_cms/auth_controller.rb +28 -0
- data/app/controllers/rivet_cms/components_controller.rb +93 -2
- data/app/controllers/rivet_cms/content_controller.rb +105 -0
- data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
- data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
- data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
- data/app/controllers/rivet_cms/documents_controller.rb +204 -0
- data/app/controllers/rivet_cms/fields_controller.rb +77 -74
- data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
- data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
- data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
- data/app/controllers/rivet_cms/setup_controller.rb +60 -0
- data/app/controllers/rivet_cms/trash_controller.rb +68 -0
- data/app/controllers/rivet_cms/users_controller.rb +117 -0
- data/app/helpers/rivet_cms/application_helper.rb +13 -3
- data/app/helpers/rivet_cms/components_helper.rb +4 -0
- data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
- data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
- data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
- data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
- data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
- data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
- data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
- data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
- data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
- data/app/models/rivet_cms/api_token.rb +64 -0
- data/app/models/rivet_cms/category.rb +20 -0
- data/app/models/rivet_cms/component.rb +10 -0
- data/app/models/rivet_cms/component_instance.rb +27 -0
- data/app/models/rivet_cms/content_type.rb +36 -23
- data/app/models/rivet_cms/content_value.rb +19 -0
- data/app/models/rivet_cms/current.rb +6 -0
- data/app/models/rivet_cms/document.rb +71 -0
- data/app/models/rivet_cms/document_revision.rb +128 -0
- data/app/models/rivet_cms/entry.rb +73 -0
- data/app/models/rivet_cms/entry_collection.rb +41 -0
- data/app/models/rivet_cms/field.rb +203 -61
- data/app/models/rivet_cms/media_asset.rb +109 -0
- data/app/models/rivet_cms/organization.rb +46 -0
- data/app/models/rivet_cms/relation.rb +23 -0
- data/app/models/rivet_cms/user.rb +61 -0
- data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
- data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
- data/app/services/rivet_cms/content_query.rb +158 -0
- data/app/services/rivet_cms/content_validator.rb +150 -0
- data/app/services/rivet_cms/draft_writer.rb +74 -0
- data/app/services/rivet_cms/open_api_generator.rb +173 -221
- data/app/services/rivet_cms/revision_pruner.rb +64 -0
- data/app/services/rivet_cms/webhooks.rb +61 -0
- data/app/views/layouts/rivet_cms/application.html.erb +35 -42
- data/config/routes.rb +68 -45
- data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
- data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
- data/db/migrate/20251121020625_create_rivet_cms_components.rb +14 -0
- data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
- data/db/migrate/20260119185946_create_content_system_tables.rb +95 -0
- data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
- data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
- data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
- data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
- data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
- data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
- data/db/seeds/README.md +70 -0
- data/db/seeds/templates/blog.rb +39 -0
- data/db/seeds/templates/events.rb +12 -0
- data/db/seeds/templates/faq.rb +9 -0
- data/db/seeds/templates/pages.rb +35 -0
- data/db/seeds/templates/site_settings.rb +22 -0
- data/db/seeds/templates/team.rb +11 -0
- data/db/seeds/templates/testimonials.rb +11 -0
- data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
- data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
- data/lib/rivet_cms/access_check.rb +12 -0
- data/lib/rivet_cms/audit.rb +43 -0
- data/lib/rivet_cms/engine.rb +28 -22
- data/lib/rivet_cms/hooks.rb +106 -0
- data/lib/rivet_cms/navigation.rb +111 -0
- data/lib/rivet_cms/safe_pattern.rb +40 -0
- data/lib/rivet_cms/seeds.rb +264 -0
- data/lib/rivet_cms/version.rb +1 -1
- data/lib/rivet_cms.rb +289 -32
- data/lib/tasks/rivet_cms_tasks.rake +18 -4
- metadata +138 -98
- data/MIT-LICENSE +0 -20
- data/app/assets/builds/rivet_cms.js.map +0 -7
- data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
- data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
- data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
- data/app/helpers/rivet_cms/flash_helper.rb +0 -37
- data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
- data/app/javascript/controllers/content_type_form_controller.js +0 -53
- data/app/javascript/controllers/field_layout_controller.js +0 -709
- data/app/javascript/rivet_cms.js +0 -29
- data/app/models/rivet_cms/content.rb +0 -4
- data/app/models/rivet_cms/field_values/base.rb +0 -11
- data/app/models/rivet_cms/field_values/boolean.rb +0 -4
- data/app/models/rivet_cms/field_values/integer.rb +0 -4
- data/app/models/rivet_cms/field_values/string.rb +0 -4
- data/app/models/rivet_cms/field_values/text.rb +0 -4
- data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
- data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
- data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
- data/app/views/rivet_cms/content_types/index.html.erb +0 -151
- data/app/views/rivet_cms/content_types/new.html.erb +0 -19
- data/app/views/rivet_cms/content_types/show.html.erb +0 -147
- data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
- data/app/views/rivet_cms/fields/_form.html.erb +0 -111
- data/app/views/rivet_cms/fields/edit.html.erb +0 -25
- data/app/views/rivet_cms/fields/index.html.erb +0 -126
- data/app/views/rivet_cms/fields/new.html.erb +0 -25
- data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
- data/config/i18n-tasks.yml +0 -178
- data/config/locales/en.yml +0 -14
- data/db/migrate/20250317194359_create_core_tables.rb +0 -90
- data/lib/rivet_cms/routes.rb +0 -49
|
@@ -1,10 +1,60 @@
|
|
|
1
1
|
module RivetCms
|
|
2
2
|
class DashboardController < ApplicationController
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 :
|
|
4
|
-
before_action :
|
|
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
|
-
|
|
17
|
+
field = @owner.fields.build(field_params)
|
|
18
|
+
field.organization = @owner.organization
|
|
20
19
|
|
|
21
|
-
if
|
|
22
|
-
|
|
20
|
+
if field.save
|
|
21
|
+
audit "field.created", field
|
|
22
|
+
redirect_to owner_path, notice: "Field created"
|
|
23
23
|
else
|
|
24
|
-
|
|
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
|
-
|
|
30
|
+
audit "field.updated", @field
|
|
31
|
+
redirect_to owner_path, notice: "Field updated"
|
|
44
32
|
else
|
|
45
|
-
|
|
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.
|
|
60
|
-
|
|
38
|
+
@field.discard
|
|
39
|
+
audit "field.removed", @field
|
|
40
|
+
redirect_to owner_path, notice: "Field removed"
|
|
61
41
|
end
|
|
62
42
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
98
|
-
@
|
|
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 = @
|
|
105
|
+
@field = @owner.fields.find(params[:id])
|
|
103
106
|
end
|
|
104
107
|
|
|
105
108
|
def field_params
|
|
106
|
-
params.require(:field).permit(:
|
|
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
|