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.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -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 +203 -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 +14 -0
  67. data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
  68. data/db/migrate/20260119185946_create_content_system_tables.rb +95 -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,25 +1,148 @@
1
- @layer base {
2
- :root {
3
- /* Brand color variables will be set dynamically */
4
- --color-brand-50: '';
5
- --color-brand-100: '';
6
- --color-brand-200: '';
7
- --color-brand-300: '';
8
- --color-brand-400: '';
9
- --color-brand-500: '';
10
- --color-brand-600: '';
11
- --color-brand-700: '';
12
- --color-brand-800: '';
13
- --color-brand-900: '';
14
- --color-brand-950: '';
15
- }
16
- }
17
-
18
- @import url('https://fonts.googleapis.com/css2?family=Passion+One:wght@400;700;900&display=swap');
19
-
20
1
  @import "tailwindcss";
21
- @import "./brand_colors";
2
+ @source "../../../javascript";
3
+ @plugin "daisyui" {
4
+ themes: light --default, dark --prefersdark, lofi;
5
+ }
22
6
 
23
7
  @theme {
24
- --font-passion-one: "Passion One", sans-serif;
25
- }
8
+ --font-sans: "Archivo", ui-sans-serif, system-ui, sans-serif;
9
+ --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
10
+ }
11
+
12
+ /* Steel ramp from the RivetCMS design system; orange is wordmark-only */
13
+ :root {
14
+ --steel-0: #ffffff;
15
+ --steel-1: #f7f7f6;
16
+ --steel-2: #efefed;
17
+ --steel-3: #dededb;
18
+ --steel-4: #b8b8b3;
19
+ --steel-5: #8e8e88;
20
+ --steel-6: #5a5a55;
21
+ --steel-7: #3a3a38;
22
+ --steel-8: #262624;
23
+ --steel-9: #171716;
24
+ --steel-10: #101010;
25
+ --orange-5: #ff5c00;
26
+ --shadow-card: 0 1px 2px rgba(16, 16, 16, 0.07);
27
+ --shadow-raised: 0 2px 6px rgba(16, 16, 16, 0.11);
28
+ --shadow-overlay: 0 8px 28px rgba(16, 16, 16, 0.2);
29
+ }
30
+
31
+ /* Design tokens shared by every theme: compact controls, unified radii */
32
+ html[data-theme] {
33
+ --size-field: 0.21875rem; /* md controls: 35px tall instead of 40px */
34
+ --size-selector: 0.21875rem; /* checkboxes, toggles, badges scale down to match */
35
+ --radius-field: 0.5rem;
36
+ --radius-selector: 0.375rem;
37
+ --radius-box: 0.75rem;
38
+ --border: 1px;
39
+ --depth: 0;
40
+ --noise: 0;
41
+ font-family: var(--font-sans);
42
+ }
43
+
44
+ /* Flat buttons that drop 1px on press */
45
+ .btn:not(:disabled, .btn-disabled):active {
46
+ transform: translateY(1px);
47
+ }
48
+
49
+ html[data-theme="lofi"] {
50
+ --color-base-100: var(--steel-0);
51
+ --color-base-200: var(--steel-1);
52
+ --color-base-300: var(--steel-3);
53
+ --color-base-content: var(--steel-9);
54
+ --color-primary: var(--steel-9);
55
+ --color-primary-content: #ffffff;
56
+ --color-secondary: var(--steel-6);
57
+ --color-secondary-content: #ffffff;
58
+ --color-accent: var(--steel-7);
59
+ --color-accent-content: #ffffff;
60
+ --color-neutral: var(--steel-8);
61
+ --color-neutral-content: #ffffff;
62
+ --color-info: #3a6ea5;
63
+ --color-info-content: #ffffff;
64
+ --color-success: #2f7d46;
65
+ --color-success-content: #ffffff;
66
+ --color-warning: #c98a00;
67
+ --color-warning-content: #101010;
68
+ --color-error: #c8351f;
69
+ --color-error-content: #ffffff;
70
+ }
71
+
72
+ html[data-theme="dark"] {
73
+ --color-base-100: var(--steel-9);
74
+ --color-base-200: var(--steel-10);
75
+ --color-base-300: var(--steel-7);
76
+ --color-base-content: #f2f2f0;
77
+ --color-primary: var(--steel-1);
78
+ --color-primary-content: var(--steel-9);
79
+ --color-secondary: var(--steel-4);
80
+ --color-secondary-content: var(--steel-9);
81
+ --color-accent: var(--steel-3);
82
+ --color-accent-content: var(--steel-9);
83
+ --color-neutral: var(--steel-2);
84
+ --color-neutral-content: var(--steel-9);
85
+ --color-info: #6d9bc9;
86
+ --color-info-content: #101010;
87
+ --color-success: #5aa672;
88
+ --color-success-content: #101010;
89
+ --color-warning: #d9a63a;
90
+ --color-warning-content: #101010;
91
+ --color-error: #d96a52;
92
+ --color-error-content: #101010;
93
+ }
94
+
95
+ /* Focus: single 1px accent border instead of DaisyUI's offset outline */
96
+ .input:focus, .input:focus-within,
97
+ .textarea:focus, .textarea:focus-within,
98
+ .select:focus, .select:focus-within,
99
+ .file-input:focus, .file-input:focus-within {
100
+ outline: none;
101
+ border-color: var(--color-primary);
102
+ box-shadow: none;
103
+ }
104
+
105
+ /* Rendered rich text / markdown preview — themed with base tokens, not prose gray */
106
+ .rivet-prose { line-height: 1.7; }
107
+ .rivet-prose :where(h1) { font-size: 1.4rem; font-weight: 700; margin: 1rem 0 0.5rem; }
108
+ .rivet-prose :where(h2) { font-size: 1.2rem; font-weight: 700; margin: 0.9rem 0 0.5rem; }
109
+ .rivet-prose :where(h3) { font-size: 1.05rem; font-weight: 600; margin: 0.75rem 0 0.4rem; }
110
+ .rivet-prose :where(p) { margin: 0.5rem 0; }
111
+ .rivet-prose :where(ul) { list-style: disc; padding-left: 1.4rem; margin: 0.5rem 0; }
112
+ .rivet-prose :where(ol) { list-style: decimal; padding-left: 1.4rem; margin: 0.5rem 0; }
113
+ .rivet-prose :where(li) { margin: 0.2rem 0; }
114
+ .rivet-prose :where(blockquote) { border-left: 3px solid var(--color-base-300); padding-left: 0.75rem; margin: 0.5rem 0; color: color-mix(in oklab, var(--color-base-content) 70%, transparent); }
115
+ .rivet-prose :where(code) { font-family: var(--font-mono); font-size: 0.85em; background: var(--color-base-200); padding: 0.1em 0.3em; border-radius: 0.25rem; }
116
+ .rivet-prose :where(pre) { background: var(--color-base-200); padding: 0.75rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; }
117
+ .rivet-prose :where(pre code) { background: none; padding: 0; }
118
+ .rivet-prose :where(a) { color: var(--color-primary); text-decoration: underline; }
119
+ .rivet-prose :where(> :first-child) { margin-top: 0; }
120
+ .rivet-prose :where(> :last-child) { margin-bottom: 0; }
121
+ .rivet-prose::after { content: ""; display: table; clear: both; }
122
+ .rivet-prose :where(img) { max-width: 100%; }
123
+
124
+ dl {
125
+ font-size: var(--text-sm);
126
+ dt {
127
+ font-weight: var(--font-weight-bold);
128
+ margin-top: calc(var(--spacing)*4);
129
+ }
130
+ }
131
+
132
+ label:has(+ input:required):after {
133
+ content: " *"
134
+ }
135
+
136
+ input:user-invalid, .field_with_errors input {
137
+ @apply input-error;
138
+ }
139
+
140
+ label:has(+ input:user-invalid), .field_with_errors label {
141
+ @apply text-error;
142
+ }
143
+
144
+ /* Literal color: ::backdrop cannot be trusted to inherit custom properties,
145
+ so a variable-backed utility silently fails in some browsers */
146
+ dialog::backdrop {
147
+ background-color: rgb(0 0 0 / 0.4);
148
+ }
@@ -0,0 +1,239 @@
1
+ module RivetCms
2
+ # Serializes models into plain hashes for Inertia page props.
3
+ # Paths are included server-side so the React app never needs to know
4
+ # where the engine is mounted.
5
+ module InertiaProps
6
+ private
7
+
8
+ def content_type_props(content_type)
9
+ {
10
+ id: content_type.id,
11
+ param: content_type.to_param,
12
+ name: content_type.name,
13
+ slug: content_type.slug,
14
+ description: content_type.description,
15
+ single: content_type.single?,
16
+ paths: {
17
+ show: content_type_path(content_type),
18
+ update: content_type_path(content_type),
19
+ destroy: content_type_path(content_type),
20
+ fields: content_type_fields_path(content_type),
21
+ update_layout: update_layout_content_type_fields_path(content_type),
22
+ documents: content_type_documents_path(content_type),
23
+ new_document: new_content_type_document_path(content_type)
24
+ }
25
+ }
26
+ end
27
+
28
+ # Identity a content page needs, without schema detail or management paths
29
+ def content_type_ref_props(content_type)
30
+ {
31
+ id: content_type.id,
32
+ param: content_type.to_param,
33
+ name: content_type.name,
34
+ slug: content_type.slug,
35
+ single: content_type.single?,
36
+ paths: {
37
+ show: content_type_path(content_type),
38
+ documents: content_type_documents_path(content_type),
39
+ new_document: new_content_type_document_path(content_type)
40
+ }
41
+ }
42
+ end
43
+
44
+ def field_props(field)
45
+ {
46
+ id: field.id,
47
+ param: field.to_param,
48
+ key: field.key,
49
+ label: field.label,
50
+ field_type: field.field_type,
51
+ field_type_label: field.field_type_label,
52
+ description: field.description,
53
+ required: field.required?,
54
+ width: field.width,
55
+ row: field.row,
56
+ position: field.position,
57
+ paired: field.paired?,
58
+ min_items: field.min_items,
59
+ max_items: field.max_items,
60
+ config: field.config || {},
61
+ paths: field_paths(field)
62
+ }
63
+ end
64
+
65
+ def field_paths(field)
66
+ if field.component_id
67
+ component = field.component
68
+ {
69
+ update: component_field_path(component, field),
70
+ destroy: component_field_path(component, field),
71
+ toggle_width: toggle_width_component_field_path(component, field),
72
+ pair: pair_component_field_path(component, field),
73
+ unpair: unpair_component_field_path(component, field)
74
+ }
75
+ else
76
+ content_type = field.content_type
77
+ {
78
+ update: content_type_field_path(content_type, field),
79
+ destroy: content_type_field_path(content_type, field),
80
+ toggle_width: toggle_width_content_type_field_path(content_type, field),
81
+ pair: pair_content_type_field_path(content_type, field),
82
+ unpair: unpair_content_type_field_path(content_type, field)
83
+ }
84
+ end
85
+ end
86
+
87
+ def document_props(document)
88
+ {
89
+ id: document.id,
90
+ param: document.to_param,
91
+ slug: document.slug,
92
+ published: document.published_revision_id.present?,
93
+ paths: {
94
+ edit: edit_content_type_document_path(document.content_type, document),
95
+ update: content_type_document_path(document.content_type, document),
96
+ publish: publish_content_type_document_path(document.content_type, document),
97
+ destroy: content_type_document_path(document.content_type, document)
98
+ }
99
+ }
100
+ end
101
+
102
+ # Display titles for a page of documents: each type's first string field,
103
+ # read from the draft revisions in one query. Keyed by revision id (a
104
+ # revision only holds values for its own type's fields).
105
+ def document_titles(documents)
106
+ type_ids = documents.map(&:content_type_id).uniq
107
+ title_field_ids = Field.kept.where(content_type_id: type_ids, field_type: "string").ordered
108
+ .group_by(&:content_type_id).filter_map { |_, fields| fields.first&.id }
109
+ revision_ids = documents.filter_map(&:draft_revision_id)
110
+ return {} if title_field_ids.empty? || revision_ids.empty?
111
+
112
+ ContentValue.where(owner_type: "RivetCms::DocumentRevision", owner_id: revision_ids, field_id: title_field_ids)
113
+ .pluck(:owner_id, :string_value).to_h
114
+ end
115
+
116
+ def document_list_props(document, titles)
117
+ document_props(document).merge(
118
+ title: document.draft_revision_id && titles[document.draft_revision_id],
119
+ author: document.draft_revision&.author_name,
120
+ updated_at: document.updated_at.iso8601
121
+ )
122
+ end
123
+
124
+ def entry_field_props(field)
125
+ {
126
+ key: field.key,
127
+ label: field.label,
128
+ field_type: field.field_type,
129
+ field_type_label: field.field_type_label,
130
+ description: field.description,
131
+ required: field.required?,
132
+ width: field.width,
133
+ row: field.row,
134
+ position: field.position,
135
+ min_items: field.min_items,
136
+ max_items: field.max_items,
137
+ config: field.config || {},
138
+ component: embedded_component_props(field)
139
+ }
140
+ end
141
+
142
+ def embedded_component_props(field)
143
+ return nil unless field.field_type == "component"
144
+
145
+ component = Component.find_by(id: field.config&.dig("component_id"), organization_id: field.organization_id)
146
+ return nil unless component
147
+
148
+ { id: component.id, name: component.name, fields: component.fields.kept.ordered.map { |f| entry_field_props(f) } }
149
+ end
150
+
151
+ def draft_values(revision)
152
+ return {} if revision.nil?
153
+
154
+ owner_values(revision, revision.document.content_type.fields.kept)
155
+ end
156
+
157
+ def owner_values(owner, fields)
158
+ values_by_field = owner.content_values
159
+ .includes(:field, media_asset: { file_attachment: :blob })
160
+ .index_by(&:field_id)
161
+ relations_by_field = owner.relations.order(:position).group_by(&:field_id)
162
+ instances_by_field = owner.component_instances.includes(component: :fields).order(:position).group_by(&:field_id)
163
+
164
+ fields.each_with_object({}) do |field, values|
165
+ values[field.key] = editable_value(field, values_by_field, relations_by_field, instances_by_field)
166
+ end
167
+ end
168
+
169
+ def editable_value(field, values_by_field, relations_by_field, instances_by_field)
170
+ case field.field_type
171
+ when "reference"
172
+ relations_by_field.fetch(field.id, []).map(&:target_document_id)
173
+ when "component"
174
+ instances_by_field.fetch(field.id, []).map do |instance|
175
+ { values: owner_values(instance, instance.component.fields.kept) }
176
+ end
177
+ when "image", "video", "file"
178
+ media_asset_json(values_by_field[field.id]&.media_asset)
179
+ else
180
+ values_by_field[field.id]&.value
181
+ end
182
+ end
183
+
184
+ def media_asset_json(asset)
185
+ return nil unless asset
186
+
187
+ {
188
+ id: asset.id,
189
+ kind: asset.kind,
190
+ filename: asset.filename,
191
+ content_type: asset.content_type,
192
+ byte_size: asset.byte_size,
193
+ title: asset.title,
194
+ alt: asset.alt,
195
+ description: asset.description,
196
+ created_at: asset.created_at&.iso8601,
197
+ url: asset.url,
198
+ thumbnail_url: asset.thumbnail_url,
199
+ paths: { update: media_asset_path(asset), destroy: media_asset_path(asset) }
200
+ }
201
+ end
202
+
203
+ def api_token_json(token)
204
+ {
205
+ id: token.id,
206
+ name: token.name,
207
+ scope: token.scope,
208
+ masked: token.masked,
209
+ last_used_at: token.last_used_at,
210
+ expires_at: token.expires_at,
211
+ created_at: token.created_at,
212
+ paths: { destroy: api_token_path(token) }
213
+ }
214
+ end
215
+
216
+ def component_props(component)
217
+ {
218
+ id: component.id,
219
+ param: component.to_param,
220
+ name: component.name,
221
+ slug: component.slug,
222
+ description: component.description,
223
+ category_id: component.category_id,
224
+ category_name: component.category&.name,
225
+ paths: {
226
+ show: component_path(component),
227
+ update: component_path(component),
228
+ destroy: component_path(component),
229
+ fields: component_fields_path(component),
230
+ update_layout: update_layout_component_fields_path(component)
231
+ }
232
+ }
233
+ end
234
+
235
+ def category_props(category)
236
+ { id: category.id, name: category.name }
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,47 @@
1
+ module RivetCms
2
+ class ApiDocsController < ApplicationController
3
+ include InertiaProps
4
+
5
+ before_action -> { authorize! :read, :api }
6
+ # The docs render every type's fields, the same data /content_types gates
7
+ before_action -> { authorize! :read, :schema }
8
+
9
+ def show
10
+ content_types = permitted_types
11
+
12
+ render inertia: "Api/Index", props: {
13
+ base_url: api_base_url,
14
+ public_api: RivetCms.public_api,
15
+ content_types: content_types.map { |ct| api_reference_props(ct) },
16
+ doc_paths: { openapi: api_openapi_path, tokens: api_tokens_path }
17
+ }
18
+ end
19
+
20
+ def spec
21
+ generator = OpenApiGenerator.new(Current.organization, base_url: api_base_url, content_types: permitted_types,
22
+ components: permitted(Current.organization.components, :read, :schema))
23
+ send_data JSON.pretty_generate(generator.as_json), type: :json, disposition: "attachment", filename: "openapi.json"
24
+ end
25
+
26
+ private
27
+
28
+ # Both renderings expose full field schemas, so both filter through the
29
+ # record phase like the /content_types page itself
30
+ def permitted_types
31
+ permitted(Current.organization.content_types.order(:name), :read, :schema)
32
+ end
33
+
34
+ def api_base_url
35
+ "#{request.base_url}/api"
36
+ end
37
+
38
+ def api_reference_props(content_type)
39
+ {
40
+ name: content_type.name,
41
+ slug: content_type.slug,
42
+ single: content_type.single?,
43
+ fields: content_type.fields.kept.ordered.map { |f| { key: f.key, type: f.field_type, required: f.required? } }
44
+ }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,43 @@
1
+ module RivetCms
2
+ class ApiTokensController < ApplicationController
3
+ include InertiaProps
4
+
5
+ before_action -> { authorize! :read, :api }, only: [ :index ]
6
+ before_action -> { authorize! :write, :api }, except: [ :index, :destroy ]
7
+ before_action -> { authorize! :delete, :api }, only: [ :destroy ]
8
+ # Tokens read content through the delivery API, so minting one requires
9
+ # content read; otherwise a content-denied user could escalate via a token.
10
+ before_action -> { authorize! :read, :content }, only: [ :create ]
11
+
12
+ def index
13
+ render inertia: "ApiTokens/Index", props: {
14
+ tokens: permitted(api_tokens.recent, :read, :api).map { |token| api_token_json(token) },
15
+ new_token: flash[:new_token]
16
+ }
17
+ end
18
+
19
+ def create
20
+ scope = params[:scope].presence_in(%w[published preview]) || "published"
21
+ token = ApiToken.generate!(name: params[:name].to_s, scope: scope)
22
+ audit "api_token.created", token, scope: scope
23
+ flash[:new_token] = token.plaintext
24
+ redirect_to api_tokens_path, notice: "API token created — copy it now; it won't be shown again."
25
+ rescue ActiveRecord::RecordInvalid => e
26
+ redirect_to api_tokens_path, alert: e.record.errors.full_messages.to_sentence
27
+ end
28
+
29
+ def destroy
30
+ token = api_tokens.find(params[:id])
31
+ authorize! :delete, :api, record: token
32
+ token.destroy
33
+ audit "api_token.revoked", token
34
+ redirect_to api_tokens_path, notice: "API token revoked"
35
+ end
36
+
37
+ private
38
+
39
+ def api_tokens
40
+ Current.organization.api_tokens
41
+ end
42
+ end
43
+ end