iron-cms 0.12.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d060f858dd0cc96cf89c5c0195a6073813325c20f324d80d164ee11f4d299b15
4
- data.tar.gz: 6856bd27d67600a115fa00926e4be52bc94357fd61eef6cda694b0074f545c68
3
+ metadata.gz: 6f8165107a118a6f3a8dcefe95984760653d8baf16f96653642382fc73ce8aca
4
+ data.tar.gz: 7143fd0c4f78a53e4b945f52005cc1f68e225674667f0f6a8bce8dcb8b77a02d
5
5
  SHA512:
6
- metadata.gz: 3aa25a33717ca01a28a7f286beefc4fd2dc2486621533922afe660ccb2dd141dfe2dd2316735747896c3f5ae2bb322a3e91f82b06cae01e1da7b9e0e7e8930ad
7
- data.tar.gz: 4d33417da4ec5a717ad5c1e3430662117e42408917a7d0587861db28e6a7d15f0618ac3fb321442de942b92bff5532bf6ae4dfb9d60ba9edd7806213cfaf038a
6
+ metadata.gz: e09696ccbfb17bf2a82d515e7297f097a4a6fbdeb8a30626bf1cb1e44818ae9f55f260fa6b7e04acce6707d1d212305318cddf6db545f62d04b37d919191c73f
7
+ data.tar.gz: be54e07e53ca096cb56e66887eab33085f76545716a61dfa7976866303f224867d9766eceaa338a65ecd3182d5b2b7af2003226d98c52d64dd17a68b912cc7e3
data/README.md CHANGED
@@ -96,37 +96,36 @@ For cases where you need a basic responsive image without format optimization:
96
96
 
97
97
  This generates a standard `<img>` with srcset but without modern format variants.
98
98
 
99
- ### Testing
99
+ ### Development Seeding
100
100
 
101
- Iron provides test fixtures to populate your test database with CMS schema and content, eliminating the need to stub the SDK in your tests.
101
+ Iron provides a seeding mechanism to snapshot and restore CMS schema and content, making it easy for teammates to get a consistent development database.
102
102
 
103
- #### Generating Fixtures
103
+ #### Creating a seed
104
104
 
105
- Generate test fixtures from your development database (or production if you prefer):
105
+ After setting up your content types and sample content via the admin UI:
106
106
 
107
107
  ```bash
108
- bin/rails iron:fixtures:generate
108
+ bin/rails iron:seed:dump
109
109
  ```
110
110
 
111
- This creates two files in `test/fixtures/iron/`:
111
+ This exports the full CMS state to `db/seeds/iron.zip`. Commit this file to your repository.
112
112
 
113
- - `schema.tar.gz` - Content types, field definitions, and block definitions
114
- - `content.tar.gz` - All entries and their field data
113
+ #### Loading a seed
115
114
 
116
- Regenerate these fixtures whenever you make changes to your CMS schema or content that you want reflected in tests.
117
-
118
- #### Using Fixtures in Tests
119
-
120
- Load the fixtures in your `test/test_helper.rb`:
115
+ Add this to your `db/seeds.rb`:
121
116
 
122
117
  ```ruby
123
- class ActiveSupport::TestCase
124
- fixtures :all
125
- Iron::TestFixtures.load
126
- end
118
+ Iron::Seed.load
127
119
  ```
128
120
 
129
- The fixtures are loaded once at the start of your test suite. Rails' transactional tests provide test isolation by rolling back changes after each test.
121
+ Then `rails db:prepare` (or `rails db:seed`) will automatically bootstrap the CMS when the database is empty. Loading is skipped if content types already exist.
122
+
123
+ #### Admin credentials
124
+
125
+ The seed creates a default admin user during loading. Configure via environment variables:
126
+
127
+ - `IRON_SEED_EMAIL` (default: `admin@example.com`)
128
+ - `IRON_SEED_PASSWORD` (default: `password`)
130
129
 
131
130
  ## System Requirements
132
131
 
@@ -3429,6 +3429,9 @@
3429
3429
  .opacity-0 {
3430
3430
  opacity: 0%;
3431
3431
  }
3432
+ .opacity-50 {
3433
+ opacity: 50%;
3434
+ }
3432
3435
  .shadow {
3433
3436
  --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
3434
3437
  box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -33,7 +33,8 @@ module Iron
33
33
 
34
34
 
35
35
  def resume_session
36
- Current.session ||= find_session_by_cookie
36
+ session = find_session_by_cookie
37
+ Current.session ||= session if session&.user&.active?
37
38
  end
38
39
 
39
40
  def find_session_by_cookie
@@ -9,7 +9,7 @@ module Iron
9
9
  end
10
10
 
11
11
  def create
12
- if user = User.find_by(email_address: params[:email_address])
12
+ if user = User.active.find_by(email_address: params[:email_address])
13
13
  Iron::PasswordsMailer.reset(user).deliver_later
14
14
  end
15
15
 
@@ -36,6 +36,7 @@ module Iron
36
36
 
37
37
  def set_user_by_token
38
38
  @user = User.find_by_password_reset_token!(params[:token])
39
+ redirect_to new_password_url, alert: t("iron.passwords.alerts.reset_link_invalid") unless @user.active?
39
40
  rescue ActiveSupport::MessageVerifier::InvalidSignature
40
41
  redirect_to new_password_url, alert: t("iron.passwords.alerts.reset_link_invalid")
41
42
  end
@@ -9,7 +9,7 @@ module Iron
9
9
  end
10
10
 
11
11
  def update
12
- if user = User.find_by_transfer_id(params[:id])
12
+ if (user = User.find_by_transfer_id(params[:id])) && user.active?
13
13
  start_new_session_for user
14
14
  redirect_to after_authentication_url
15
15
  else
@@ -10,7 +10,7 @@ module Iron
10
10
  end
11
11
 
12
12
  def create
13
- if user = User.authenticate_by(params.permit(:email_address, :password))
13
+ if user = User.active.authenticate_by(params.permit(:email_address, :password))
14
14
  start_new_session_for user
15
15
  redirect_to after_authentication_url
16
16
  else
@@ -0,0 +1,18 @@
1
+ module Iron
2
+ module Users
3
+ class ReactivationsController < ApplicationController
4
+ before_action :set_user
5
+ before_action :ensure_can_administer
6
+
7
+ def update
8
+ @user.reactivate
9
+ redirect_to @user, notice: t("iron.users.notifications.reactivated")
10
+ end
11
+
12
+ private
13
+ def set_user
14
+ @user = User.find(params.expect(:user_id))
15
+ end
16
+ end
17
+ end
18
+ end
@@ -14,7 +14,7 @@ module Iron
14
14
 
15
15
  private
16
16
  def set_user
17
- @user = User.find(params.expect(:user_id))
17
+ @user = User.active.find(params.expect(:user_id))
18
18
  end
19
19
  end
20
20
  end
@@ -8,7 +8,8 @@ module Iron
8
8
  before_action :ensure_can_administer, only: %i[ index destroy ]
9
9
 
10
10
  def index
11
- @users = User.all
11
+ @users = User.active
12
+ @deactivated_users = User.where(active: false)
12
13
  end
13
14
 
14
15
  def new
@@ -31,8 +32,8 @@ module Iron
31
32
  end
32
33
 
33
34
  def destroy
34
- if @user.destroy
35
- redirect_back fallback_location: users_path, notice: t("iron.users.notifications.removed"), status: :see_other
35
+ if @user.deactivate
36
+ redirect_to users_path, notice: t("iron.users.notifications.removed"), status: :see_other
36
37
  else
37
38
  redirect_back fallback_location: users_path, alert: @user.errors.full_messages.to_sentence, status: :see_other
38
39
  end
@@ -40,7 +41,7 @@ module Iron
40
41
 
41
42
  private
42
43
  def set_user
43
- @user = User.find(params.expect(:id))
44
+ @user = User.active.find(params.expect(:id))
44
45
  end
45
46
 
46
47
  def join_params
@@ -17,70 +17,12 @@ module Iron
17
17
  def perform
18
18
  raise ArgumentError, "Nothing selected to export" unless include_schema? || include_content?
19
19
 
20
- zipfile = generate_zip
21
- file.attach(
22
- io: File.open(zipfile.path),
23
- filename: "iron-export-#{id}.zip",
24
- content_type: "application/zip"
25
- )
20
+ zipfile = Tempfile.new([ "export", ".zip" ])
21
+ Exporter.new(zipfile.path, include_schema: include_schema?, include_content: include_content?).export
22
+ file.attach(io: File.open(zipfile.path), filename: "iron-export-#{id}.zip", content_type: "application/zip")
26
23
  ensure
27
24
  zipfile&.close
28
25
  zipfile&.unlink
29
26
  end
30
-
31
- def generate_zip
32
- Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
33
- Zip::File.open(tempfile.path, create: true) do |zip|
34
- add_schema_to_zip(zip) if include_schema?
35
- add_content_to_zip(zip) if include_content?
36
- end
37
- end
38
- end
39
-
40
- def add_schema_to_zip(zip)
41
- zip.get_output_stream("schema.json") { |f| f.write(export_schema_json) }
42
- end
43
-
44
- def add_content_to_zip(zip)
45
- exportable_entries.find_each do |entry|
46
- path = "entries/#{entry.content_type.handle}/#{entry.id}.json"
47
- zip.get_output_stream(path) { |f| f.write(entry.export_json) }
48
-
49
- entry.export_attachments.each do |attachment|
50
- zip.get_output_stream("files/#{attachment[:path]}", compression_method: Zip::Entry::STORED) do |f|
51
- attachment[:blob].download { |chunk| f.write(chunk) }
52
- end
53
- rescue ActiveStorage::FileNotFoundError
54
- # Skip missing files
55
- end
56
- end
57
- end
58
-
59
- def export_schema_json
60
- JSON.pretty_generate(
61
- version: "1.0",
62
- exported_at: Time.current.iso8601,
63
- block_definitions: export_block_definitions,
64
- content_types: export_content_types
65
- )
66
- end
67
-
68
- def export_block_definitions
69
- BlockDefinition
70
- .includes(:field_definitions)
71
- .order(:handle)
72
- .map(&:export_attributes)
73
- end
74
-
75
- def export_content_types
76
- ContentType
77
- .includes(:title_field_definition, :web_page_title_field_definition, :field_definitions)
78
- .order(:handle)
79
- .map(&:export_attributes)
80
- end
81
-
82
- def exportable_entries
83
- Entry.includes(:content_type, :creator, fields: [ :locale, :definition ])
84
- end
85
27
  end
86
28
  end
@@ -18,190 +18,12 @@ module Iron
18
18
  def perform
19
19
  raise ArgumentError, "Nothing selected to import" unless include_schema? || include_content?
20
20
 
21
- import_from_zip
22
- end
23
-
24
- def import_from_zip
25
21
  Tempfile.create([ "import", ".zip" ]) do |tempfile|
26
22
  tempfile.binmode
27
23
  tempfile.write(file.download)
28
24
  tempfile.rewind
29
25
 
30
- Zip::File.open(tempfile.path) do |zip|
31
- @zip = zip
32
- @files_dir = extract_files_to_temp_dir(zip)
33
-
34
- import_schema_from_zip if include_schema?
35
- import_content_from_zip if include_content?
36
- ensure
37
- FileUtils.rm_rf(@files_dir) if @files_dir
38
- end
39
- end
40
- end
41
-
42
- def extract_files_to_temp_dir(zip)
43
- dir = Dir.mktmpdir("iron-import")
44
- zip.glob("files/**/*").each do |entry|
45
- next if entry.directory?
46
-
47
- path = File.join(dir, entry.name.sub("files/", ""))
48
- FileUtils.mkdir_p(File.dirname(path))
49
- entry.extract(path)
50
- end
51
- dir
52
- end
53
-
54
- def import_schema_from_zip
55
- return unless @zip.find_entry("schema.json")
56
-
57
- schema_json = @zip.read("schema.json")
58
- schema = parse_json(schema_json)
59
- raise ArgumentError, "Invalid schema JSON format" unless schema
60
-
61
- ActiveRecord::Base.transaction do
62
- import_block_definitions(schema[:block_definitions] || [])
63
- import_content_types(schema[:content_types] || [])
64
- resolve_content_type_references(schema[:content_types] || [])
65
- end
66
- end
67
-
68
- def import_content_from_zip
69
- ActiveRecord::Base.transaction do
70
- id_mapping = {}
71
- entries_data = []
72
-
73
- # First pass: read all entry files and create entries
74
- @zip.glob("entries/**/*.json").each do |zip_entry|
75
- attrs = parse_json(zip_entry.get_input_stream.read)
76
- next unless attrs
77
-
78
- entries_data << attrs
79
- entry = Entry.import_from_attributes(attrs, @files_dir)
80
- id_mapping[attrs[:id]] = entry if entry
81
- end
82
-
83
- # Second pass: resolve references
84
- resolve_references(entries_data, id_mapping)
85
- end
86
- end
87
-
88
- def resolve_references(entries_data, id_mapping)
89
- entries_data.each do |attrs|
90
- entry = id_mapping[attrs[:id]]
91
- next unless entry
92
-
93
- (attrs[:fields] || {}).each do |locale_code, field_data|
94
- locale = Locale.find_by(code: locale_code.to_s)
95
- next unless locale
96
-
97
- resolve_references_in_fields(entry, locale, field_data, id_mapping)
98
- end
99
- end
100
- end
101
-
102
- def resolve_references_in_fields(entry, locale, fields_data, id_mapping, parent: nil)
103
- fields_data.each do |handle, value_data|
104
- next unless value_data.is_a?(Hash)
105
-
106
- case value_data[:type]
107
- when "reference"
108
- resolve_reference_field(entry, locale, handle.to_s, value_data[:value], id_mapping, parent: parent)
109
- when "reference_list"
110
- resolve_reference_list_field(entry, locale, handle.to_s, value_data[:value], id_mapping, parent: parent)
111
- when "block"
112
- resolve_references_in_block(entry, locale, handle.to_s, value_data, id_mapping, parent: parent)
113
- when "block_list"
114
- resolve_references_in_block_list(entry, locale, handle.to_s, value_data, id_mapping, parent: parent)
115
- end
116
- end
117
- end
118
-
119
- def resolve_references_in_block(entry, locale, handle, value_data, id_mapping, parent: nil)
120
- block_field = find_field(entry, locale, handle, parent)
121
- return unless block_field
122
-
123
- resolve_references_in_fields(entry, locale, value_data[:fields] || {}, id_mapping, parent: block_field)
124
- end
125
-
126
- def resolve_references_in_block_list(entry, locale, handle, value_data, id_mapping, parent: nil)
127
- block_list_field = find_field(entry, locale, handle, parent)
128
- return unless block_list_field
129
-
130
- # Reload blocks and get them in rank order (matching import order)
131
- ordered_blocks = block_list_field.blocks.reload.sort_by(&:rank)
132
-
133
- (value_data[:value] || []).each_with_index do |block_data, index|
134
- block = ordered_blocks[index]
135
- next unless block
136
-
137
- resolve_references_in_fields(entry, locale, block_data[:fields] || {}, id_mapping, parent: block)
138
- end
139
- end
140
-
141
- def find_field(entry, locale, handle, parent)
142
- # Reload associations to get freshly persisted fields from the first pass
143
- fields = parent&.fields&.reload || entry.fields.reload
144
- definition_scope = parent.is_a?(Fields::Block) ? parent.block_definition.field_definitions : entry.content_type.field_definitions
145
- definition = definition_scope.find_by(handle: handle)
146
- return unless definition
147
-
148
- fields.find { |f| f.definition_id == definition.id && f.locale_id == locale.id }
149
- end
150
-
151
- def resolve_reference_field(entry, locale, handle, old_entry_id, id_mapping, parent: nil)
152
- return unless old_entry_id
153
-
154
- referenced_entry = id_mapping[old_entry_id]
155
- return unless referenced_entry
156
-
157
- field = find_field(entry, locale, handle, parent)
158
- return unless field
159
-
160
- field.update!(referenced_entry: referenced_entry)
161
- end
162
-
163
- def resolve_reference_list_field(entry, locale, handle, old_entry_ids, id_mapping, parent: nil)
164
- return unless old_entry_ids.is_a?(Array)
165
-
166
- field = find_field(entry, locale, handle, parent)
167
- return unless field
168
-
169
- old_entry_ids.each_with_index do |old_id, index|
170
- referenced_entry = id_mapping[old_id]
171
- next unless referenced_entry
172
-
173
- field.references.create!(entry: referenced_entry, rank: index)
174
- end
175
- end
176
-
177
- def parse_json(content)
178
- JSON.parse(content, symbolize_names: true)
179
- rescue JSON::ParserError
180
- nil
181
- end
182
-
183
- def import_block_definitions(definitions)
184
- definitions.each { |attrs| BlockDefinition.import_from_attributes(attrs) }
185
- end
186
-
187
- def import_content_types(definitions)
188
- definitions.each { |attrs| ContentType.import_from_attributes(attrs) }
189
- end
190
-
191
- def resolve_content_type_references(definitions)
192
- definitions.each do |attrs|
193
- content_type = ContentType.find_by(handle: attrs[:handle])
194
- next unless content_type
195
-
196
- if attrs[:title_field_handle].present?
197
- field = content_type.field_definitions.find_by(handle: attrs[:title_field_handle])
198
- content_type.update!(title_field_definition: field) if field
199
- end
200
-
201
- if attrs[:web_page_title_field_handle].present?
202
- field = content_type.field_definitions.find_by(handle: attrs[:web_page_title_field_handle])
203
- content_type.update!(web_page_title_field_definition: field) if field
204
- end
26
+ Importer.new(tempfile.path, include_schema: include_schema?, include_content: include_content?).import
205
27
  end
206
28
  end
207
29
  end
@@ -4,21 +4,15 @@ module Iron
4
4
 
5
5
  class_methods do
6
6
  def import_from_attributes(attrs)
7
- block_def = find_or_initialize_by(handle: attrs[:handle])
8
- block_def.update!(
9
- name: attrs[:name],
10
- description: attrs[:description]
11
- )
12
-
13
- import_field_definitions(block_def, attrs[:field_definitions] || [])
14
-
15
- block_def
7
+ find_or_initialize_by(handle: attrs[:handle]).tap do |block_def|
8
+ block_def.update!(name: attrs[:name], description: attrs[:description])
9
+ end
16
10
  end
17
11
 
18
- private
12
+ def populate_field_definitions(attrs)
13
+ block_def = find_by!(handle: attrs[:handle])
19
14
 
20
- def import_field_definitions(block_def, field_attrs_list)
21
- field_attrs_list.each do |field_attrs|
15
+ (attrs[:field_definitions] || []).each do |field_attrs|
22
16
  FieldDefinition.import_from_attributes(field_attrs, schemable: block_def)
23
17
  end
24
18
  end
@@ -0,0 +1,75 @@
1
+ module Iron
2
+ class Exporter
3
+ def initialize(path, include_schema: true, include_content: true)
4
+ @path = path.to_s
5
+ @include_schema = include_schema
6
+ @include_content = include_content
7
+ end
8
+
9
+ def export
10
+ Zip::File.open(path, create: true) do |zip|
11
+ write_schema(zip) if schema?
12
+ write_entries(zip) if content?
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :path
19
+
20
+ def schema? = @include_schema
21
+ def content? = @include_content
22
+
23
+ def write_schema(zip)
24
+ zip.get_output_stream("schema.json") { |f| f.write(schema_json) }
25
+ end
26
+
27
+ def write_entries(zip)
28
+ exportable_entries.find_each do |entry|
29
+ path = "entries/#{entry.content_type.handle}/#{entry.id}.json"
30
+ zip.get_output_stream(path) { |f| f.write(entry.export_json) }
31
+
32
+ entry.export_attachments.each do |attachment|
33
+ zip.get_output_stream("files/#{attachment[:path]}", compression_method: Zip::Entry::STORED) do |f|
34
+ attachment[:blob].download { |chunk| f.write(chunk) }
35
+ end
36
+ rescue ActiveStorage::FileNotFoundError
37
+ # Skip missing files
38
+ end
39
+ end
40
+ end
41
+
42
+ def schema_json
43
+ JSON.pretty_generate(
44
+ version: "1.0",
45
+ exported_at: Time.current.iso8601,
46
+ default_locale: Current.account&.default_locale&.code,
47
+ locales: export_locales,
48
+ block_definitions: export_block_definitions,
49
+ content_types: export_content_types
50
+ )
51
+ end
52
+
53
+ def export_locales
54
+ Locale.order(:code).map { |l| { code: l.code, name: l.name } }
55
+ end
56
+
57
+ def export_block_definitions
58
+ BlockDefinition
59
+ .includes(:field_definitions)
60
+ .order(:handle)
61
+ .map(&:export_attributes)
62
+ end
63
+
64
+ def export_content_types
65
+ ContentType
66
+ .includes(:title_field_definition, :web_page_title_field_definition, :field_definitions)
67
+ .order(:handle)
68
+ .map(&:export_attributes)
69
+ end
70
+
71
+ def exportable_entries
72
+ Entry.includes(:content_type, :creator, fields: [ :locale, :definition ])
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,211 @@
1
+ module Iron
2
+ class Importer
3
+ def initialize(path, include_schema: true, include_content: true)
4
+ @path = path.to_s
5
+ @include_schema = include_schema
6
+ @include_content = include_content
7
+ end
8
+
9
+ def import
10
+ Zip::File.open(path) do |zip|
11
+ files_dir = extract_files(zip)
12
+
13
+ import_schema(zip) if schema?
14
+ import_content(zip, files_dir) if content?
15
+ ensure
16
+ FileUtils.rm_rf(files_dir) if files_dir
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :path
23
+
24
+ def schema? = @include_schema
25
+ def content? = @include_content
26
+
27
+ def extract_files(zip)
28
+ dir = Dir.mktmpdir("iron-import")
29
+ zip.glob("files/**/*").each do |entry|
30
+ next if entry.directory?
31
+
32
+ path = File.join(dir, entry.name.sub("files/", ""))
33
+ FileUtils.mkdir_p(File.dirname(path))
34
+ entry.extract(path)
35
+ end
36
+ dir
37
+ end
38
+
39
+ def import_schema(zip)
40
+ return unless zip.find_entry("schema.json")
41
+
42
+ schema = read_schema(zip)
43
+
44
+ ActiveRecord::Base.transaction do
45
+ import_locales(schema[:locales] || [])
46
+ import_block_definitions(schema[:block_definitions] || [])
47
+ populate_block_field_definitions(schema[:block_definitions] || [])
48
+ import_content_types(schema[:content_types] || [])
49
+ resolve_content_type_references(schema[:content_types] || [])
50
+ restore_default_locale(schema[:default_locale])
51
+ end
52
+ end
53
+
54
+ def import_content(zip, files_dir)
55
+ ActiveRecord::Base.transaction do
56
+ id_mapping = {}
57
+ entries_data = []
58
+
59
+ # First pass: create all entries
60
+ zip.glob("entries/**/*.json").each do |zip_entry|
61
+ attrs = parse_json(zip_entry.get_input_stream.read)
62
+ entries_data << attrs
63
+ entry = Entry.import_from_attributes(attrs, files_dir)
64
+ id_mapping[attrs[:id]] = entry if entry
65
+ end
66
+
67
+ # Second pass: resolve references
68
+ resolve_references(entries_data, id_mapping)
69
+ end
70
+ end
71
+
72
+ def resolve_references(entries_data, id_mapping)
73
+ entries_data.each do |attrs|
74
+ entry = id_mapping[attrs[:id]]
75
+ next unless entry
76
+
77
+ (attrs[:fields] || {}).each do |locale_code, field_data|
78
+ locale = Locale.find_by(code: locale_code.to_s)
79
+ next unless locale
80
+
81
+ resolve_references_in_fields(entry, locale, field_data, id_mapping)
82
+ end
83
+ end
84
+ end
85
+
86
+ def resolve_references_in_fields(entry, locale, fields_data, id_mapping, parent: nil)
87
+ fields_data.each do |handle, value_data|
88
+ next unless value_data.is_a?(Hash)
89
+
90
+ case value_data[:type]
91
+ when "reference"
92
+ resolve_reference_field(entry, locale, handle.to_s, value_data[:value], id_mapping, parent: parent)
93
+ when "reference_list"
94
+ resolve_reference_list_field(entry, locale, handle.to_s, value_data[:value], id_mapping, parent: parent)
95
+ when "block"
96
+ resolve_references_in_block(entry, locale, handle.to_s, value_data, id_mapping, parent: parent)
97
+ when "block_list"
98
+ resolve_references_in_block_list(entry, locale, handle.to_s, value_data, id_mapping, parent: parent)
99
+ end
100
+ end
101
+ end
102
+
103
+ def resolve_references_in_block(entry, locale, handle, value_data, id_mapping, parent: nil)
104
+ block_field = find_field(entry, locale, handle, parent)
105
+ return unless block_field
106
+
107
+ resolve_references_in_fields(entry, locale, value_data[:fields] || {}, id_mapping, parent: block_field)
108
+ end
109
+
110
+ def resolve_references_in_block_list(entry, locale, handle, value_data, id_mapping, parent: nil)
111
+ block_list_field = find_field(entry, locale, handle, parent)
112
+ return unless block_list_field
113
+
114
+ ordered_blocks = block_list_field.blocks.reload.sort_by(&:rank)
115
+
116
+ (value_data[:value] || []).each_with_index do |block_data, index|
117
+ block = ordered_blocks[index]
118
+ next unless block
119
+
120
+ resolve_references_in_fields(entry, locale, block_data[:fields] || {}, id_mapping, parent: block)
121
+ end
122
+ end
123
+
124
+ def find_field(entry, locale, handle, parent)
125
+ fields = parent&.fields&.reload || entry.fields.reload
126
+ definition_scope = parent.is_a?(Fields::Block) ? parent.block_definition.field_definitions : entry.content_type.field_definitions
127
+ definition = definition_scope.find_by(handle: handle)
128
+ return unless definition
129
+
130
+ fields.find { |f| f.definition_id == definition.id && f.locale_id == locale.id }
131
+ end
132
+
133
+ def resolve_reference_field(entry, locale, handle, old_entry_id, id_mapping, parent: nil)
134
+ return unless old_entry_id
135
+
136
+ referenced_entry = id_mapping[old_entry_id]
137
+ return unless referenced_entry
138
+
139
+ field = find_field(entry, locale, handle, parent)
140
+ return unless field
141
+
142
+ field.update!(referenced_entry: referenced_entry)
143
+ end
144
+
145
+ def resolve_reference_list_field(entry, locale, handle, old_entry_ids, id_mapping, parent: nil)
146
+ return unless old_entry_ids.is_a?(Array)
147
+
148
+ field = find_field(entry, locale, handle, parent)
149
+ return unless field
150
+
151
+ old_entry_ids.each_with_index do |old_id, index|
152
+ referenced_entry = id_mapping[old_id]
153
+ next unless referenced_entry
154
+
155
+ field.references.create!(entry: referenced_entry, rank: index)
156
+ end
157
+ end
158
+
159
+ def read_schema(zip)
160
+ parse_json(zip.read("schema.json"))
161
+ end
162
+
163
+ def parse_json(content)
164
+ JSON.parse(content, symbolize_names: true)
165
+ end
166
+
167
+ def import_locales(locales)
168
+ locales.each do |attrs|
169
+ Locale.find_or_create_by!(code: attrs[:code]) do |locale|
170
+ locale.name = attrs[:name]
171
+ end
172
+ end
173
+ end
174
+
175
+ def restore_default_locale(locale_code)
176
+ return unless locale_code
177
+
178
+ locale = Locale.find_by(code: locale_code)
179
+ Current.account.update!(default_locale: locale) if locale
180
+ end
181
+
182
+ def import_block_definitions(definitions)
183
+ definitions.each { |attrs| BlockDefinition.import_from_attributes(attrs) }
184
+ end
185
+
186
+ def populate_block_field_definitions(definitions)
187
+ definitions.each { |attrs| BlockDefinition.populate_field_definitions(attrs) }
188
+ end
189
+
190
+ def import_content_types(definitions)
191
+ definitions.each { |attrs| ContentType.import_from_attributes(attrs) }
192
+ end
193
+
194
+ def resolve_content_type_references(definitions)
195
+ definitions.each do |attrs|
196
+ content_type = ContentType.find_by(handle: attrs[:handle])
197
+ next unless content_type
198
+
199
+ if attrs[:title_field_handle].present?
200
+ field = content_type.field_definitions.find_by(handle: attrs[:title_field_handle])
201
+ content_type.update!(title_field_definition: field) if field
202
+ end
203
+
204
+ if attrs[:web_page_title_field_handle].present?
205
+ field = content_type.field_definitions.find_by(handle: attrs[:web_page_title_field_handle])
206
+ content_type.update!(web_page_title_field_definition: field) if field
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,77 @@
1
+ module Iron
2
+ class Seed
3
+ DEFAULT_PATH = "db/seeds/iron.zip"
4
+
5
+ def self.dump(path = nil) = new(path).dump
6
+ def self.load(path = nil) = new(path).load
7
+
8
+ def dump
9
+ FileUtils.mkdir_p(path.dirname)
10
+ Exporter.new(path).export
11
+ end
12
+
13
+ def load
14
+ return unless path.exist?
15
+ return if already_seeded?
16
+
17
+ user, account = bootstrap
18
+ Current.set(user: user, account: account) do
19
+ Importer.new(path).import
20
+ end
21
+ end
22
+
23
+ private_class_method :new
24
+
25
+ private
26
+
27
+ attr_reader :path
28
+
29
+ def initialize(path)
30
+ @path = Pathname.new(path || Rails.root.join(DEFAULT_PATH))
31
+ end
32
+
33
+ def already_seeded?
34
+ ContentType.any?
35
+ end
36
+
37
+ def bootstrap
38
+ locale = Locale.first || Locale.create!(**default_locale_attributes)
39
+ account = Account.first || Account.create!(name: "Iron", default_locale: locale)
40
+ user = User.first || User.create!(
41
+ email_address: seed_email,
42
+ password: seed_password,
43
+ role: :administrator
44
+ )
45
+ [ user, account ]
46
+ end
47
+
48
+ def seed_email
49
+ ENV["IRON_SEED_EMAIL"] || default_credential("admin@example.com")
50
+ end
51
+
52
+ def seed_password
53
+ ENV["IRON_SEED_PASSWORD"] || default_credential("password")
54
+ end
55
+
56
+ def default_credential(value)
57
+ raise "IRON_SEED_EMAIL and IRON_SEED_PASSWORD must be set in #{Rails.env}" unless Rails.env.local?
58
+ value
59
+ end
60
+
61
+ def default_locale_attributes
62
+ schema = read_seed_schema
63
+ locale_code = schema&.dig(:default_locale)
64
+ locale_attrs = schema&.dig(:locales)&.find { |l| l[:code] == locale_code }
65
+ locale_attrs&.slice(:code, :name) || { code: "en", name: "English" }
66
+ end
67
+
68
+ def read_seed_schema
69
+ return unless path.exist?
70
+
71
+ Zip::File.open(path.to_s) do |zip|
72
+ entry = zip.find_entry("schema.json")
73
+ JSON.parse(entry.get_input_stream.read, symbolize_names: true) if entry
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,23 @@
1
+ module Iron::User::Deactivatable
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ scope :active, -> { where(active: true) }
6
+ end
7
+
8
+ def deactivate
9
+ if current?
10
+ errors.add(:base, :cannot_deactivate_self)
11
+ return false
12
+ end
13
+
14
+ transaction do
15
+ sessions.destroy_all
16
+ update!(active: false)
17
+ end
18
+ end
19
+
20
+ def reactivate
21
+ update!(active: true)
22
+ end
23
+ end
@@ -1,5 +1,6 @@
1
1
  module Iron
2
2
  class User < ApplicationRecord
3
+ include Deactivatable
3
4
  include Role
4
5
  include Transferable
5
6
 
@@ -7,8 +8,6 @@ module Iron
7
8
 
8
9
  validates :language, inclusion: { in: ADMIN_LANGUAGES }
9
10
 
10
- before_destroy :ensure_not_current_user
11
-
12
11
  has_secure_password
13
12
  has_many :sessions, dependent: :destroy
14
13
  has_many :exports, class_name: "Iron::Account::Export", dependent: :destroy
@@ -33,12 +32,5 @@ module Iron
33
32
  def email_local_part
34
33
  email_address.split("@").first
35
34
  end
36
-
37
- def ensure_not_current_user
38
- if Current.user == self
39
- errors.add(:base, :cannot_delete_self)
40
- throw :abort
41
- end
42
- end
43
35
  end
44
36
  end
@@ -25,4 +25,29 @@
25
25
  <%= render @users %>
26
26
  </div>
27
27
  </section>
28
+
29
+ <% if @deactivated_users.any? %>
30
+ <section>
31
+ <h2 class="text-sm font-medium text-stone-500 dark:text-stone-400 mb-3"><%= t(".deactivated") %></h2>
32
+ <div class="card card-list">
33
+ <% @deactivated_users.each do |user| %>
34
+ <div class="card-item flex items-center justify-between gap-4">
35
+ <div class="flex items-center gap-3 min-w-0 flex-1">
36
+ <%= avatar user, class: "size-9 bg-stone-100 text-stone-500 text-[14px] dark:bg-stone-700 dark:text-stone-300 opacity-50" %>
37
+ <div class="min-w-0">
38
+ <span class="block text-sm font-medium text-stone-400 dark:text-stone-500 truncate"><%= user.name %></span>
39
+ <p class="text-xs text-stone-400 dark:text-stone-500 truncate"><%= user.email_address %></p>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="flex items-center gap-2 shrink-0">
44
+ <%= button_to user_reactivation_path(user), method: :patch, class: "button-ghost button-sm" do %>
45
+ <%= t(".reactivate") %>
46
+ <% end %>
47
+ </div>
48
+ </div>
49
+ <% end %>
50
+ </div>
51
+ </section>
52
+ <% end %>
28
53
  </div>
@@ -30,7 +30,7 @@ en:
30
30
  iron/user:
31
31
  attributes:
32
32
  base:
33
- cannot_delete_self: "You cannot delete your own account while logged in"
33
+ cannot_deactivate_self: "You cannot delete your own account while logged in"
34
34
  role:
35
35
  cannot_change_own_role: "cannot be changed for your own account"
36
36
  iron:
@@ -455,8 +455,10 @@ en:
455
455
  count:
456
456
  one: "1 member"
457
457
  other: "%{count} members"
458
+ deactivated: "Deactivated"
458
459
  heading: "Users"
459
460
  invite: "Invite"
461
+ reactivate: "Reactivate"
460
462
  team: "Team"
461
463
  title: "Users"
462
464
  invite:
@@ -475,6 +477,7 @@ en:
475
477
  title: "Join %{account}"
476
478
  notifications:
477
479
  password_changed: "Password changed."
480
+ reactivated: "User was successfully reactivated."
478
481
  removed: "User was successfully removed."
479
482
  roles:
480
483
  administrator: "Administrator"
@@ -69,7 +69,7 @@ it:
69
69
  iron/user:
70
70
  attributes:
71
71
  base:
72
- cannot_delete_self: "Non puoi eliminare il tuo account mentre sei autenticato"
72
+ cannot_deactivate_self: "Non puoi eliminare il tuo account mentre sei autenticato"
73
73
  role:
74
74
  cannot_change_own_role: "non può essere cambiato per il tuo account"
75
75
  iron:
@@ -494,8 +494,10 @@ it:
494
494
  count:
495
495
  one: "1 membro"
496
496
  other: "%{count} membri"
497
+ deactivated: "Disattivati"
497
498
  heading: "Utenti"
498
499
  invite: "Invita"
500
+ reactivate: "Riattiva"
499
501
  team: "Team"
500
502
  title: "Utenti"
501
503
  invite:
@@ -514,6 +516,7 @@ it:
514
516
  title: "Unisciti a %{account}"
515
517
  notifications:
516
518
  password_changed: "Password cambiata."
519
+ reactivated: "Utente riattivato con successo."
517
520
  removed: "Utente rimosso con successo."
518
521
  roles:
519
522
  administrator: "Amministratore"
data/config/routes.rb CHANGED
@@ -32,6 +32,7 @@ Iron::Engine.routes.draw do
32
32
  resource :email, only: :update
33
33
  resource :password, only: :update
34
34
  resource :language, only: :update
35
+ resource :reactivation, only: :update
35
36
  resource :role, only: :update
36
37
  end
37
38
  end
@@ -0,0 +1,5 @@
1
+ class AddActiveToIronUsers < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :iron_users, :active, :boolean, default: true, null: false
4
+ end
5
+ end
data/lib/iron/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Iron
2
- VERSION = "0.12.0"
2
+ VERSION = "0.13.0"
3
3
  end
@@ -1,24 +1,15 @@
1
1
  namespace :iron do
2
- namespace :fixtures do
3
- desc "Generate test fixtures from current database"
4
- task generate: :environment do
5
- fixture_dir = Rails.root.join("test/fixtures/iron")
6
- FileUtils.mkdir_p(fixture_dir)
7
-
8
- schema_path = fixture_dir.join("schema.tar.gz")
9
- content_path = fixture_dir.join("content.tar.gz")
10
-
11
- puts "Exporting Iron schema to #{schema_path}..."
12
- schema_tar = Iron::SchemaExporter.export
13
- File.binwrite(schema_path, schema_tar)
14
-
15
- puts "Exporting Iron content to #{content_path}..."
16
- content_archive = Iron::ContentExport.export
17
- content_archive.to_file(content_path)
2
+ namespace :seed do
3
+ desc "Dump CMS schema and content to db/seeds/iron.zip"
4
+ task dump: :environment do
5
+ path = ENV["SEED_PATH"]
6
+ Iron::Seed.dump(path)
7
+ puts "Iron CMS seed exported to #{path || Rails.root.join(Iron::Seed::DEFAULT_PATH)}"
8
+ end
18
9
 
19
- puts "Test fixtures generated successfully!"
20
- puts " - Schema: #{schema_path}"
21
- puts " - Content: #{content_path}"
10
+ desc "Load CMS schema and content from db/seeds/iron.zip"
11
+ task load: :environment do
12
+ Iron::Seed.load(ENV["SEED_PATH"])
22
13
  end
23
14
  end
24
15
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iron-cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Massimo De Marchi
@@ -286,6 +286,7 @@ files:
286
286
  - app/controllers/iron/users/emails_controller.rb
287
287
  - app/controllers/iron/users/languages_controller.rb
288
288
  - app/controllers/iron/users/passwords_controller.rb
289
+ - app/controllers/iron/users/reactivations_controller.rb
289
290
  - app/controllers/iron/users/roles_controller.rb
290
291
  - app/controllers/iron/users_controller.rb
291
292
  - app/helpers/iron/application_helper.rb
@@ -351,6 +352,7 @@ files:
351
352
  - app/models/iron/entry/schemable.rb
352
353
  - app/models/iron/entry/titlable.rb
353
354
  - app/models/iron/entry/web_publishable.rb
355
+ - app/models/iron/exporter.rb
354
356
  - app/models/iron/field.rb
355
357
  - app/models/iron/field/belongs_to_entry.rb
356
358
  - app/models/iron/field_definition.rb
@@ -380,11 +382,14 @@ files:
380
382
  - app/models/iron/fields/text_field.rb
381
383
  - app/models/iron/first_run.rb
382
384
  - app/models/iron/icon_catalog.rb
385
+ - app/models/iron/importer.rb
383
386
  - app/models/iron/locale.rb
384
387
  - app/models/iron/qr_code_link.rb
385
388
  - app/models/iron/reference.rb
389
+ - app/models/iron/seed.rb
386
390
  - app/models/iron/session.rb
387
391
  - app/models/iron/user.rb
392
+ - app/models/iron/user/deactivatable.rb
388
393
  - app/models/iron/user/role.rb
389
394
  - app/models/iron/user/transferable.rb
390
395
  - app/views/active_storage/blobs/_blob.html.erb
@@ -517,6 +522,7 @@ files:
517
522
  - db/migrate/20251209103109_create_iron_account_exports.rb
518
523
  - db/migrate/20251209103110_create_iron_account_imports.rb
519
524
  - db/migrate/20260207103057_add_language_to_iron_users.rb
525
+ - db/migrate/20260209215027_add_active_to_iron_users.rb
520
526
  - lib/generators/iron/pages/pages_generator.rb
521
527
  - lib/generators/iron/pages/templates/pages_controller.rb
522
528
  - lib/generators/iron/pages/templates/show.html.erb