b4um_generators 0.3.0 → 0.4.2

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/CHANGELOG.md +72 -0
  4. data/README.de.md +338 -4
  5. data/README.md +330 -5
  6. data/lib/b4um_generators/version.rb +1 -1
  7. data/lib/generators/b4um/admin/admin_generator.rb +725 -0
  8. data/lib/generators/b4um/admin/templates/add_admin_fields_migration.rb.tt +10 -0
  9. data/lib/generators/b4um/admin/templates/admin_base_controller.rb.tt +5 -0
  10. data/lib/generators/b4um/admin/templates/admin_dashboard.html.erb.tt +19 -0
  11. data/lib/generators/b4um/admin/templates/admin_dashboard_controller.rb.tt +6 -0
  12. data/lib/generators/b4um/admin/templates/admin_users_controller.rb.tt +70 -0
  13. data/lib/generators/b4um/admin/templates/admin_users_index.html.erb.tt +110 -0
  14. data/lib/generators/b4um/admin/templates/b4um_admin.rake.tt +106 -0
  15. data/lib/generators/b4um/authentication/authentication_generator.rb +436 -23
  16. data/lib/generators/b4um/comments/comments_generator.rb +39 -9
  17. data/lib/generators/b4um/guest/guest_generator.rb +673 -0
  18. data/lib/generators/b4um/guest/templates/add_guest_fields_migration.rb.tt +12 -0
  19. data/lib/generators/b4um/guest/templates/b4um_guests.rake.tt +12 -0
  20. data/lib/generators/b4um/guest/templates/edit.html.erb.tt +156 -0
  21. data/lib/generators/b4um/guest/templates/guest_expiration_warning.html.erb.tt +12 -0
  22. data/lib/generators/b4um/guest/templates/guests_controller.rb.tt +88 -0
  23. data/lib/generators/b4um/guest/templates/new.html.erb.tt +136 -0
  24. data/lib/generators/b4um/help/help_generator.rb +162 -0
  25. data/lib/generators/b4um/in_place/in_place_generator.rb +132 -7
  26. data/lib/generators/b4um/readable/readable_generator.rb +8 -5
  27. data/lib/generators/b4um/scaffold/scaffold_generator.rb +213 -73
  28. data/lib/generators/b4um/scaffold/templates/_form.html.erb.tt +18 -14
  29. data/lib/generators/b4um/sortable/sortable_generator.rb +104 -36
  30. metadata +16 -1
@@ -0,0 +1,725 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module B4um
7
+ module Generators
8
+ class AdminGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds admin user management to an existing B4UM authentication model."
14
+
15
+ argument :authentication_model,
16
+ type: :string,
17
+ required: true,
18
+ banner: "MODEL"
19
+
20
+ def validate_authentication_model
21
+ model_path = File.join(
22
+ destination_root,
23
+ "app/models",
24
+ "#{authentication_model_name}.rb"
25
+ )
26
+
27
+ return if File.exist?(model_path)
28
+
29
+ raise Thor::Error,
30
+ "Authentication model #{authentication_class_name} was not found. " \
31
+ "Expected: app/models/#{authentication_model_name}.rb"
32
+ end
33
+
34
+ def validate_authentication_setup
35
+ application_controller_path = File.join(
36
+ destination_root,
37
+ "app/controllers/application_controller.rb"
38
+ )
39
+
40
+ authentication_helper =
41
+ "helper_method :#{current_authentication_method}, :logged_in?"
42
+
43
+ authentication_installed =
44
+ File.exist?(application_controller_path) &&
45
+ File.read(application_controller_path).include?(authentication_helper)
46
+
47
+ unless authentication_installed
48
+ raise Thor::Error,
49
+ "b4um authentication is not installed for " \
50
+ "#{authentication_class_name}. " \
51
+ "Run: bin/rails generate b4um:authentication " \
52
+ "#{authentication_class_name}"
53
+ end
54
+
55
+ application_controller =
56
+ File.read(application_controller_path)
57
+
58
+ current_user_assignment =
59
+ "@#{current_authentication_method} ||= " \
60
+ "#{authentication_class_name}.find_by(" \
61
+ "id: session[:#{authentication_model_name}_id])"
62
+
63
+ unless application_controller.include?(current_user_assignment)
64
+ raise Thor::Error,
65
+ "Could not find the current authentication method " \
66
+ "in app/controllers/application_controller.rb"
67
+ end
68
+
69
+ sessions_controller_path = File.join(
70
+ destination_root,
71
+ "app/controllers/sessions_controller.rb"
72
+ )
73
+
74
+ unless File.exist?(sessions_controller_path)
75
+ raise Thor::Error,
76
+ "SessionsController not found: " \
77
+ "app/controllers/sessions_controller.rb"
78
+ end
79
+
80
+ sessions_controller = File.read(sessions_controller_path)
81
+
82
+ session_assignment =
83
+ "session[:#{authentication_model_name}_id] = " \
84
+ "#{authentication_model_name}.id"
85
+
86
+ return if sessions_controller.include?(session_assignment)
87
+
88
+ raise Thor::Error,
89
+ "Could not find the authentication session assignment " \
90
+ "in app/controllers/sessions_controller.rb"
91
+ end
92
+
93
+ def protect_admin_fields
94
+ controller_path = File.join(
95
+ destination_root,
96
+ "app/controllers",
97
+ "#{authentication_model_name.pluralize}_controller.rb"
98
+ )
99
+
100
+ return unless File.exist?(controller_path)
101
+
102
+ controller = File.read(controller_path)
103
+
104
+ params_method = controller.match(
105
+ /def #{Regexp.escape(authentication_model_name)}_params\b.*?^\s*end$/m
106
+ )
107
+
108
+ return unless params_method
109
+
110
+ protected_params = params_method[0].dup
111
+
112
+ %w[admin blocked].each do |field|
113
+ protected_params.gsub!(
114
+ /:#{field}\s*,?/,
115
+ ""
116
+ )
117
+ end
118
+
119
+ controller.sub!(
120
+ params_method[0],
121
+ protected_params
122
+ )
123
+
124
+ File.write(
125
+ controller_path,
126
+ controller
127
+ )
128
+ end
129
+
130
+ def create_admin_fields_migration
131
+ return if admin_fields_exist?
132
+
133
+ migration_template(
134
+ "add_admin_fields_migration.rb.tt",
135
+ "db/migrate/add_admin_fields_to_#{authentication_model_name.pluralize}.rb"
136
+ )
137
+ end
138
+
139
+ def add_admin_helper
140
+ controller_path = "app/controllers/application_controller.rb"
141
+
142
+ full_controller_path = File.join(
143
+ destination_root,
144
+ controller_path
145
+ )
146
+
147
+ controller = File.read(full_controller_path)
148
+
149
+ require_admin = [
150
+ "",
151
+ " def require_admin",
152
+ " return if logged_in? && #{current_authentication_method}.admin?",
153
+ "",
154
+ ' redirect_to root_path, alert: "Access denied."',
155
+ " end",
156
+ ""
157
+ ].join("\n")
158
+
159
+ if behavior == :revoke
160
+ return unless controller.include?(require_admin)
161
+
162
+ File.write(
163
+ full_controller_path,
164
+ controller.sub(require_admin, "")
165
+ )
166
+
167
+ return
168
+ end
169
+
170
+ return if controller.include?("def require_admin")
171
+
172
+ class_end_position = controller.rindex(/^end\s*$/)
173
+
174
+ unless class_end_position
175
+ raise Thor::Error,
176
+ "Could not find the end of ApplicationController."
177
+ end
178
+
179
+ controller.insert(
180
+ class_end_position,
181
+ require_admin
182
+ )
183
+
184
+ File.write(
185
+ full_controller_path,
186
+ controller
187
+ )
188
+ end
189
+
190
+ def create_admin_base_controller
191
+ template(
192
+ "admin_base_controller.rb.tt",
193
+ "app/controllers/admin/base_controller.rb"
194
+ )
195
+ end
196
+
197
+ def create_admin_dashboard_controller
198
+ template(
199
+ "admin_dashboard_controller.rb.tt",
200
+ "app/controllers/admin/dashboard_controller.rb"
201
+ )
202
+ end
203
+
204
+ def create_admin_users_controller
205
+ template(
206
+ "admin_users_controller.rb.tt",
207
+ "app/controllers/admin/users_controller.rb"
208
+ )
209
+ end
210
+
211
+ def create_admin_dashboard_view
212
+ template(
213
+ "admin_dashboard.html.erb.tt",
214
+ "app/views/admin/dashboard/index.html.erb"
215
+ )
216
+ end
217
+
218
+ def create_admin_users_index_view
219
+ template(
220
+ "admin_users_index.html.erb.tt",
221
+ "app/views/admin/users/index.html.erb"
222
+ )
223
+ end
224
+
225
+ def create_admin_creation_task
226
+ template(
227
+ "b4um_admin.rake.tt",
228
+ "lib/tasks/b4um_admin.rake"
229
+ )
230
+ end
231
+
232
+ def add_admin_route
233
+ routes_path = "config/routes.rb"
234
+ full_routes_path = File.join(destination_root, routes_path)
235
+
236
+ unless File.exist?(full_routes_path)
237
+ raise Thor::Error,
238
+ "Routes not found: #{routes_path}"
239
+ end
240
+
241
+ if behavior == :revoke
242
+ routes = File.read(full_routes_path)
243
+
244
+ routes.gsub!(
245
+ /^ #{Regexp.escape(admin_root_route)}\n?/,
246
+ ""
247
+ )
248
+
249
+ routes.gsub!(
250
+ /^#{Regexp.escape(indented_admin_users_route)}\n?/,
251
+ ""
252
+ )
253
+
254
+ File.write(
255
+ full_routes_path,
256
+ routes
257
+ )
258
+
259
+ return
260
+ end
261
+
262
+ admin_users_route_status =
263
+ upgrade_existing_admin_users_route(
264
+ routes_path,
265
+ full_routes_path
266
+ )
267
+
268
+ routes = File.read(full_routes_path)
269
+
270
+ routes_to_add = missing_admin_routes(
271
+ routes,
272
+ admin_users_route_exists:
273
+ admin_users_route_status == :upgraded
274
+ )
275
+
276
+ return if routes_to_add.empty?
277
+
278
+ inject_into_file(
279
+ routes_path,
280
+ before: /^end\s*$/
281
+ ) do
282
+ "#{routes_to_add.join("\n")}\n"
283
+ end
284
+ end
285
+
286
+ def invalidate_blocked_sessions
287
+ controller_path = "app/controllers/application_controller.rb"
288
+
289
+ full_controller_path = File.join(
290
+ destination_root,
291
+ controller_path
292
+ )
293
+
294
+ controller = File.read(full_controller_path)
295
+
296
+ current_user_assignment =
297
+ "@#{current_authentication_method} ||= " \
298
+ "#{authentication_class_name}.find_by(" \
299
+ "id: session[:#{authentication_model_name}_id])"
300
+
301
+ blocked_session_block = [
302
+ current_user_assignment,
303
+ "",
304
+ "if @#{current_authentication_method}&.blocked?",
305
+ " session.delete(:#{authentication_model_name}_id)",
306
+ " @#{current_authentication_method} = nil",
307
+ "end",
308
+ "",
309
+ "@#{current_authentication_method}"
310
+ ]
311
+
312
+ if behavior == :revoke
313
+ blocked_pattern = /
314
+ ^(?<indent>[ \t]*)#{Regexp.escape(current_user_assignment)}\n
315
+ \n
316
+ ^\k<indent>if\ @#{Regexp.escape(current_authentication_method)}&\.blocked\?\n
317
+ ^\k<indent>\ \ session\.delete\(:#{Regexp.escape(authentication_model_name)}_id\)\n
318
+ ^\k<indent>\ \ @#{Regexp.escape(current_authentication_method)}\ =\ nil\n
319
+ ^\k<indent>end\n
320
+ \n
321
+ ^\k<indent>@#{Regexp.escape(current_authentication_method)}$
322
+ /x
323
+
324
+ match = controller.match(blocked_pattern)
325
+
326
+ return unless match
327
+
328
+ replacement =
329
+ "#{match[:indent]}#{current_user_assignment}"
330
+
331
+ File.write(
332
+ full_controller_path,
333
+ controller.sub(
334
+ blocked_pattern,
335
+ replacement
336
+ )
337
+ )
338
+
339
+ return
340
+ end
341
+
342
+ return if controller.include?(
343
+ "if @#{current_authentication_method}&.blocked?"
344
+ )
345
+
346
+ assignment_match = controller.match(
347
+ /^([ \t]*)#{Regexp.escape(current_user_assignment)}$/
348
+ )
349
+
350
+ unless assignment_match
351
+ raise Thor::Error,
352
+ "Could not find the current authentication method " \
353
+ "in #{controller_path}"
354
+ end
355
+
356
+ indentation = assignment_match[1]
357
+
358
+ replacement = blocked_session_block
359
+ .map do |line|
360
+ line.empty? ? line : "#{indentation}#{line}"
361
+ end
362
+ .join("\n")
363
+
364
+ gsub_file(
365
+ controller_path,
366
+ /^#{Regexp.escape(indentation)}#{Regexp.escape(current_user_assignment)}$/,
367
+ replacement
368
+ )
369
+ end
370
+
371
+ def prevent_blocked_login
372
+ controller_path = "app/controllers/sessions_controller.rb"
373
+
374
+ full_controller_path = File.join(
375
+ destination_root,
376
+ controller_path
377
+ )
378
+
379
+ unless File.exist?(full_controller_path)
380
+ raise Thor::Error,
381
+ "SessionsController not found: #{controller_path}"
382
+ end
383
+
384
+ controller = File.read(full_controller_path)
385
+
386
+ session_assignment =
387
+ "session[:#{authentication_model_name}_id] = " \
388
+ "#{authentication_model_name}.id"
389
+
390
+ login_redirect =
391
+ 'redirect_to root_path, notice: "Successfully logged in."'
392
+
393
+ if behavior == :revoke
394
+ blocked_login_pattern = /
395
+ ^(?<indent>[ \t]*)if\ #{Regexp.escape(authentication_model_name)}\.blocked\?\n
396
+ ^\k<indent>\ \ flash\.now\[:alert\]\ =\ "Account\ is\ blocked\."\n
397
+ ^\k<indent>\ \ render\ :new,\ status:\ :unprocessable_content\n
398
+ ^\k<indent>else\n
399
+ ^\k<indent>\ \ #{Regexp.escape(session_assignment)}\n
400
+ \n
401
+ ^\k<indent>\ \ #{Regexp.escape(login_redirect)}\n
402
+ ^\k<indent>end$
403
+ /x
404
+
405
+ match = controller.match(blocked_login_pattern)
406
+
407
+ return unless match
408
+
409
+ indentation = match[:indent]
410
+
411
+ replacement = [
412
+ "#{indentation}#{session_assignment}",
413
+ "",
414
+ "#{indentation}#{login_redirect}"
415
+ ].join("\n")
416
+
417
+ File.write(
418
+ full_controller_path,
419
+ controller.sub(
420
+ blocked_login_pattern,
421
+ replacement
422
+ )
423
+ )
424
+
425
+ return
426
+ end
427
+
428
+ return if controller.include?(
429
+ "if #{authentication_model_name}.blocked?"
430
+ )
431
+
432
+ session_match = controller.match(
433
+ /^(\s*)#{Regexp.escape(session_assignment)}$/
434
+ )
435
+
436
+ unless session_match
437
+ raise Thor::Error,
438
+ "Could not find the authentication session assignment " \
439
+ "in #{controller_path}"
440
+ end
441
+
442
+ indent = session_match[1]
443
+ inner_indent = "#{indent} "
444
+
445
+ replacement = [
446
+ "#{indent}if #{authentication_model_name}.blocked?",
447
+ "#{inner_indent}flash.now[:alert] = \"Account is blocked.\"",
448
+ "#{inner_indent}render :new, status: :unprocessable_content",
449
+ "#{indent}else",
450
+ "#{inner_indent}#{session_assignment}"
451
+ ].join("\n")
452
+
453
+ gsub_file(
454
+ controller_path,
455
+ /^#{Regexp.escape(indent)}#{Regexp.escape(session_assignment)}$/,
456
+ replacement
457
+ )
458
+
459
+ controller = File.read(full_controller_path)
460
+
461
+ redirect_match = controller.match(
462
+ /^(\s*)#{Regexp.escape(login_redirect)}$/
463
+ )
464
+
465
+ unless redirect_match
466
+ raise Thor::Error,
467
+ "Could not find the successful login redirect " \
468
+ "in #{controller_path}"
469
+ end
470
+
471
+ redirect_indent = redirect_match[1]
472
+
473
+ gsub_file(
474
+ controller_path,
475
+ /^#{Regexp.escape(redirect_indent)}#{Regexp.escape(login_redirect)}$/,
476
+ [
477
+ "",
478
+ "#{inner_indent}#{login_redirect}",
479
+ "#{indent}end"
480
+ ].join("\n")
481
+ )
482
+ end
483
+
484
+ def self.next_migration_number(_dirname)
485
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
486
+ end
487
+
488
+ private
489
+
490
+ def upgrade_existing_admin_users_route(
491
+ _routes_path,
492
+ full_routes_path
493
+ )
494
+ routes = File.read(full_routes_path)
495
+
496
+ namespace_match = routes.match(
497
+ /^([ \t]*)namespace :admin do\s*$/
498
+ )
499
+
500
+ return :unchanged unless namespace_match
501
+
502
+ namespace_indent = namespace_match[1]
503
+ route_indent = "#{namespace_indent} "
504
+
505
+ namespace_start = namespace_match.end(0)
506
+
507
+ namespace_end = routes.match(
508
+ /^#{Regexp.escape(namespace_indent)}end\s*$/,
509
+ namespace_start
510
+ )
511
+
512
+ return :unchanged unless namespace_end
513
+
514
+ namespace_body = routes[
515
+ namespace_start...namespace_end.begin(0)
516
+ ]
517
+
518
+ old_route_match = namespace_body.match(
519
+ /^#{Regexp.escape(route_indent)}#{Regexp.escape(old_admin_users_route)}$/
520
+ )
521
+
522
+ return :unchanged unless old_route_match
523
+
524
+ replacement = admin_users_resources
525
+ .lines
526
+ .map { |line| "#{route_indent}#{line}" }
527
+ .join
528
+ .chomp
529
+
530
+ route_start =
531
+ namespace_start + old_route_match.begin(0)
532
+
533
+ route_end =
534
+ namespace_start + old_route_match.end(0)
535
+
536
+ routes[route_start...route_end] = replacement
537
+
538
+ File.write(
539
+ full_routes_path,
540
+ routes
541
+ )
542
+
543
+ :upgraded
544
+ end
545
+
546
+ def missing_admin_routes(
547
+ routes,
548
+ admin_users_route_exists: false
549
+ )
550
+ routes_to_add = []
551
+
552
+ routes_to_add << " #{admin_root_route}" unless routes.include?(admin_root_route)
553
+
554
+ unless admin_users_route_exists ||
555
+ routes.include?(admin_users_route)
556
+ routes_to_add << indented_admin_users_route
557
+ end
558
+
559
+ routes_to_add
560
+ end
561
+
562
+ def admin_root_route
563
+ 'get "admin", to: "admin/dashboard#index", as: :admin_root'
564
+ end
565
+
566
+ def admin_users_route
567
+ resources = admin_users_resources
568
+ .lines
569
+ .map { |line| " #{line}" }
570
+ .join
571
+ .chomp
572
+
573
+ [
574
+ "namespace :admin do",
575
+ resources,
576
+ "end"
577
+ ].join("\n")
578
+ end
579
+
580
+ def admin_users_resources
581
+ <<~RUBY.chomp
582
+ resources :users, only: [:index, :destroy] do
583
+ member do
584
+ patch :block
585
+ patch :unblock
586
+ patch :grant_admin
587
+ patch :revoke_admin
588
+ end
589
+ end
590
+ RUBY
591
+ end
592
+
593
+ def old_admin_users_route
594
+ "resources :users, only: [:index]"
595
+ end
596
+
597
+ def indented_admin_users_route
598
+ admin_users_route
599
+ .lines
600
+ .map { |line| " #{line}" }
601
+ .join
602
+ .chomp
603
+ end
604
+
605
+ def admin_creation_fields
606
+ schema_path = File.join(
607
+ destination_root,
608
+ "db/schema.rb"
609
+ )
610
+
611
+ return [] unless File.exist?(schema_path)
612
+
613
+ schema = File.read(schema_path)
614
+ table_name = authentication_model_name.pluralize
615
+
616
+ table_match = schema.match(
617
+ /create_table "#{Regexp.escape(table_name)}".*?do \|t\|(.*?)^\s*end/m
618
+ )
619
+
620
+ return [] unless table_match
621
+
622
+ excluded_fields = %w[
623
+ id
624
+ email
625
+ password_digest
626
+ admin
627
+ blocked
628
+ created_at
629
+ updated_at
630
+ ]
631
+
632
+ fields = table_match[1].scan(
633
+ /^\s*t\.(string|text)\s+"([^"]+)"/
634
+ )
635
+
636
+ fields.filter_map do |_type, field|
637
+ field unless excluded_fields.include?(field)
638
+ end
639
+ end
640
+
641
+ def admin_user_display_fields
642
+ schema_path = File.join(
643
+ destination_root,
644
+ "db/schema.rb"
645
+ )
646
+
647
+ return ["email"] unless File.exist?(schema_path)
648
+
649
+ schema = File.read(schema_path)
650
+ table_name = authentication_model_name.pluralize
651
+
652
+ table_match = schema.match(
653
+ /create_table "#{Regexp.escape(table_name)}".*?do \|t\|(.*?)^\s*end/m
654
+ )
655
+
656
+ return ["email"] unless table_match
657
+
658
+ excluded_fields = %w[
659
+ id
660
+ admin
661
+ blocked
662
+ created_at
663
+ updated_at
664
+ ]
665
+
666
+ sensitive_field_pattern = /(?:password|token|secret|digest)/i
667
+
668
+ fields = table_match[1].scan(
669
+ /^\s*t\.\w+\s+"([^"]+)"/
670
+ ).flatten
671
+
672
+ fields.reject! do |field|
673
+ excluded_fields.include?(field) ||
674
+ field.match?(sensitive_field_pattern)
675
+ end
676
+
677
+ fields.empty? ? ["email"] : fields
678
+ end
679
+
680
+ def admin_user_field_label(field)
681
+ field.humanize
682
+ end
683
+
684
+ def authentication_class_name
685
+ authentication_model.to_s.classify
686
+ end
687
+
688
+ def authentication_model_name
689
+ authentication_model.to_s.underscore
690
+ end
691
+
692
+ def current_authentication_method
693
+ "current_#{authentication_model_name}"
694
+ end
695
+
696
+ def admin_fields_exist?
697
+ admin_column_exists? && blocked_column_exists?
698
+ end
699
+
700
+ def admin_column_exists?
701
+ schema_table&.match?(/t\.boolean\s+"admin"/) || false
702
+ end
703
+
704
+ def blocked_column_exists?
705
+ schema_table&.match?(/t\.boolean\s+"blocked"/) || false
706
+ end
707
+
708
+ def schema_table
709
+ schema_path = File.join(destination_root, "db/schema.rb")
710
+
711
+ return unless File.exist?(schema_path)
712
+
713
+ schema = File.read(schema_path)
714
+
715
+ table_name = authentication_model_name.pluralize
716
+
717
+ table_pattern = /
718
+ create_table\s+"#{Regexp.escape(table_name)}".*?^ end
719
+ /mx
720
+
721
+ schema[table_pattern]
722
+ end
723
+ end
724
+ end
725
+ end