katalyst-koi 4.0.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 (206) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +23 -0
  4. data/Upgrade.md +6 -0
  5. data/app/assets/builds/koi/admin.css +1 -0
  6. data/app/assets/builds/koi/nav_items.css +1 -0
  7. data/app/assets/config/koi.js +10 -0
  8. data/app/assets/images/koi/application/chevron-right.svg +10 -0
  9. data/app/assets/images/koi/application/glyphicons-halflings-white.png +0 -0
  10. data/app/assets/images/koi/application/glyphicons-halflings.png +0 -0
  11. data/app/assets/images/koi/application/icon-collapse-down.png +0 -0
  12. data/app/assets/images/koi/application/icon-collapse-up.png +0 -0
  13. data/app/assets/images/koi/application/icon-file-doc.png +0 -0
  14. data/app/assets/images/koi/application/icon-file-img.png +0 -0
  15. data/app/assets/images/koi/application/icon-file-pdf.png +0 -0
  16. data/app/assets/images/koi/application/icon-file-ppt.png +0 -0
  17. data/app/assets/images/koi/application/icon-file-unknown.png +0 -0
  18. data/app/assets/images/koi/application/icon-file-xls.png +0 -0
  19. data/app/assets/images/koi/application/icon-file-zip.png +0 -0
  20. data/app/assets/images/koi/application/icon-form-date-picker.png +0 -0
  21. data/app/assets/images/koi/application/icon-form-error.png +0 -0
  22. data/app/assets/images/koi/application/icon-index-sort-ascending.png +0 -0
  23. data/app/assets/images/koi/application/icon-index-sort-descending.png +0 -0
  24. data/app/assets/images/koi/application/icon-index-sort.png +0 -0
  25. data/app/assets/images/koi/application/icon-index-sortable.png +0 -0
  26. data/app/assets/images/koi/application/icon-menu-cursor.png +0 -0
  27. data/app/assets/images/koi/application/icon-overlay-add.png +0 -0
  28. data/app/assets/images/koi/application/icon-overlay-close.png +0 -0
  29. data/app/assets/images/koi/application/icon-sortable.png +0 -0
  30. data/app/assets/images/koi/application/jcrop.gif +0 -0
  31. data/app/assets/images/koi/application/loading.gif +0 -0
  32. data/app/assets/images/koi/application/select-arrow.svg +3 -0
  33. data/app/assets/images/koi/application/select_arrow.png +0 -0
  34. data/app/assets/images/koi/application/sort-ascending.png +0 -0
  35. data/app/assets/images/koi/application/sort-descending.png +0 -0
  36. data/app/assets/javascripts/koi/admin.js +4 -0
  37. data/app/assets/javascripts/koi/controllers/application.js +11 -0
  38. data/app/assets/javascripts/koi/controllers/document_field_controller.js +26 -0
  39. data/app/assets/javascripts/koi/controllers/file_field_controller.js +143 -0
  40. data/app/assets/javascripts/koi/controllers/flash_controller.js +12 -0
  41. data/app/assets/javascripts/koi/controllers/form_request_submit_controller.js +11 -0
  42. data/app/assets/javascripts/koi/controllers/image_field_controller.js +24 -0
  43. data/app/assets/javascripts/koi/controllers/index.js +6 -0
  44. data/app/assets/javascripts/koi/controllers/index_actions_controller.js +61 -0
  45. data/app/assets/javascripts/koi/controllers/keyboard_controller.js +149 -0
  46. data/app/assets/javascripts/koi/controllers/navigation_controller.js +84 -0
  47. data/app/assets/javascripts/koi/controllers/navigation_toggle_controller.js +7 -0
  48. data/app/assets/javascripts/koi/controllers/show_hide_controller.js +25 -0
  49. data/app/assets/javascripts/koi/controllers/sluggable_controller.js +30 -0
  50. data/app/assets/javascripts/koi/controllers/webauthn_authentication_controller.js +23 -0
  51. data/app/assets/javascripts/koi/controllers/webauthn_registration_controller.js +30 -0
  52. data/app/assets/javascripts/koi/utils/transition.js +220 -0
  53. data/app/assets/stylesheets/koi/admin.scss +27 -0
  54. data/app/assets/stylesheets/koi/base/_button.scss +122 -0
  55. data/app/assets/stylesheets/koi/base/_icon.scss +29 -0
  56. data/app/assets/stylesheets/koi/base/_index.scss +18 -0
  57. data/app/assets/stylesheets/koi/base/_input.scss +13 -0
  58. data/app/assets/stylesheets/koi/base/_link.scss +26 -0
  59. data/app/assets/stylesheets/koi/base/_list.scss +11 -0
  60. data/app/assets/stylesheets/koi/base/_typography.scss +160 -0
  61. data/app/assets/stylesheets/koi/components/_actions-group.scss +7 -0
  62. data/app/assets/stylesheets/koi/components/_image-field.scss +33 -0
  63. data/app/assets/stylesheets/koi/components/_index-actions.scss +69 -0
  64. data/app/assets/stylesheets/koi/components/_index-table.scss +91 -0
  65. data/app/assets/stylesheets/koi/components/_index.scss +6 -0
  66. data/app/assets/stylesheets/koi/components/_item-table.scss +33 -0
  67. data/app/assets/stylesheets/koi/components/_pagy.scss +41 -0
  68. data/app/assets/stylesheets/koi/layouts/_banner.scss +7 -0
  69. data/app/assets/stylesheets/koi/layouts/_content.scss +40 -0
  70. data/app/assets/stylesheets/koi/layouts/_flash.scss +41 -0
  71. data/app/assets/stylesheets/koi/layouts/_header.scss +62 -0
  72. data/app/assets/stylesheets/koi/layouts/_index.scss +48 -0
  73. data/app/assets/stylesheets/koi/layouts/_main.scss +23 -0
  74. data/app/assets/stylesheets/koi/layouts/_navigation.scss +156 -0
  75. data/app/assets/stylesheets/koi/layouts/_stack.scss +13 -0
  76. data/app/assets/stylesheets/koi/pages/_index.scss +1 -0
  77. data/app/assets/stylesheets/koi/pages/_login.scss +40 -0
  78. data/app/assets/stylesheets/koi/themes/_content.scss +5 -0
  79. data/app/assets/stylesheets/koi/themes/_govuk.scss +52 -0
  80. data/app/assets/stylesheets/koi/themes/_index.scss +5 -0
  81. data/app/assets/stylesheets/koi/themes/_kpop.scss +5 -0
  82. data/app/assets/stylesheets/koi/themes/_navigation.scss +5 -0
  83. data/app/assets/stylesheets/koi/themes/_trix.scss +32 -0
  84. data/app/assets/stylesheets/koi/utils/_breakpoints.scss +13 -0
  85. data/app/assets/stylesheets/koi/utils/_hide.scss +11 -0
  86. data/app/assets/stylesheets/koi/utils/_index.scss +2 -0
  87. data/app/assets/stylesheets/koi/utils/_typography.scss +24 -0
  88. data/app/components/koi/header/edit_component.rb +58 -0
  89. data/app/components/koi/header/index_component.rb +23 -0
  90. data/app/components/koi/header/new_component.rb +40 -0
  91. data/app/components/koi/header/show_component.rb +51 -0
  92. data/app/components/koi/header_component.html.erb +16 -0
  93. data/app/components/koi/header_component.rb +28 -0
  94. data/app/components/koi/index_table_component.rb +21 -0
  95. data/app/controllers/admin/admin_users_controller.rb +88 -0
  96. data/app/controllers/admin/application_controller.rb +9 -0
  97. data/app/controllers/admin/caches_controller.rb +11 -0
  98. data/app/controllers/admin/credentials_controller.rb +64 -0
  99. data/app/controllers/admin/dashboards_controller.rb +7 -0
  100. data/app/controllers/admin/sessions_controller.rb +78 -0
  101. data/app/controllers/admin/url_rewrites_controller.rb +87 -0
  102. data/app/controllers/concerns/koi/controller/has_admin_users.rb +49 -0
  103. data/app/controllers/concerns/koi/controller/has_webauthn.rb +45 -0
  104. data/app/controllers/concerns/koi/controller/is_admin_controller.rb +52 -0
  105. data/app/helpers/katalyst/content/editor/errors.rb +21 -0
  106. data/app/helpers/katalyst/navigation/editor/errors.rb +21 -0
  107. data/app/helpers/koi/application_helper.rb +7 -0
  108. data/app/helpers/koi/date_helper.rb +36 -0
  109. data/app/helpers/koi/definition_list_helper.rb +92 -0
  110. data/app/helpers/koi/index_actions_helper.rb +99 -0
  111. data/app/jobs/koi/application_job.rb +6 -0
  112. data/app/mailers/koi/application_mailer.rb +8 -0
  113. data/app/models/admin/credential.rb +14 -0
  114. data/app/models/admin/user.rb +51 -0
  115. data/app/models/application_record.rb +5 -0
  116. data/app/models/concerns/koi/model/archivable.rb +55 -0
  117. data/app/models/url_rewrite.rb +25 -0
  118. data/app/views/admin/admin_users/_admin.html+row.erb +4 -0
  119. data/app/views/admin/admin_users/_authentication.html.erb +15 -0
  120. data/app/views/admin/admin_users/_fields.html.erb +4 -0
  121. data/app/views/admin/admin_users/edit.html.erb +11 -0
  122. data/app/views/admin/admin_users/index.html.erb +9 -0
  123. data/app/views/admin/admin_users/new.html.erb +11 -0
  124. data/app/views/admin/admin_users/show.html.erb +22 -0
  125. data/app/views/admin/credentials/new.html.erb +14 -0
  126. data/app/views/admin/dashboards/show.html.erb +1 -0
  127. data/app/views/admin/sessions/new.html.erb +19 -0
  128. data/app/views/admin/shared/icons/_close.html.erb +8 -0
  129. data/app/views/admin/shared/icons/_cross.html.erb +3 -0
  130. data/app/views/admin/shared/icons/_menu.html.erb +3 -0
  131. data/app/views/admin/shared/icons/_refresh.html.erb +8 -0
  132. data/app/views/admin/url_rewrites/_form_fields.html.erb +3 -0
  133. data/app/views/admin/url_rewrites/_url_rewrite.html+row.erb +7 -0
  134. data/app/views/admin/url_rewrites/edit.html.erb +12 -0
  135. data/app/views/admin/url_rewrites/index.html.erb +10 -0
  136. data/app/views/admin/url_rewrites/new.html.erb +11 -0
  137. data/app/views/admin/url_rewrites/show.html.erb +16 -0
  138. data/app/views/katalyst/content/asides/_aside.html+form.erb +18 -0
  139. data/app/views/katalyst/content/columns/_column.html+form.erb +18 -0
  140. data/app/views/katalyst/content/contents/_content.html+form.erb +20 -0
  141. data/app/views/katalyst/content/figures/_figure.html+form.erb +17 -0
  142. data/app/views/katalyst/content/groups/_group.html+form.erb +18 -0
  143. data/app/views/katalyst/content/items/_item.html+form.erb +18 -0
  144. data/app/views/katalyst/content/sections/_section.html+form.erb +18 -0
  145. data/app/views/katalyst/navigation/items/_button.html.erb +15 -0
  146. data/app/views/katalyst/navigation/items/_heading.html.erb +11 -0
  147. data/app/views/katalyst/navigation/items/_link.html.erb +13 -0
  148. data/app/views/katalyst/navigation/menus/edit.html.erb +12 -0
  149. data/app/views/katalyst/navigation/menus/new.html.erb +9 -0
  150. data/app/views/katalyst/navigation/menus/show.html.erb +18 -0
  151. data/app/views/layouts/koi/_environment.html.erb +4 -0
  152. data/app/views/layouts/koi/_flash.html.erb +8 -0
  153. data/app/views/layouts/koi/_header.html.erb +11 -0
  154. data/app/views/layouts/koi/_navigation.html.erb +13 -0
  155. data/app/views/layouts/koi/_navigation_collapse.html.erb +3 -0
  156. data/app/views/layouts/koi/_navigation_header.html.erb +6 -0
  157. data/app/views/layouts/koi/_navigation_item.html.erb +12 -0
  158. data/app/views/layouts/koi/application.html.erb +59 -0
  159. data/app/views/layouts/koi/login.html.erb +29 -0
  160. data/config/importmap.rb +9 -0
  161. data/config/initializers/flipper.rb +13 -0
  162. data/config/initializers/pagy.rb +1 -0
  163. data/config/initializers/time_formats.rb +5 -0
  164. data/config/locales/koi.en.yml +18 -0
  165. data/config/locales/pagy.en.yml +6 -0
  166. data/config/routes.rb +25 -0
  167. data/db/migrate/20120220130849_devise_create_admins.rb +56 -0
  168. data/db/migrate/20130509235316_add_url_rewriter.rb +13 -0
  169. data/db/migrate/20230213053854_convert_devise_admins_to_rails.rb +7 -0
  170. data/db/migrate/20230412023411_create_admin_user_credentials.rb +20 -0
  171. data/db/migrate/20230531063707_update_admin_users.rb +37 -0
  172. data/db/migrate/20230602033610_add_archived_to_admin_users.rb +7 -0
  173. data/db/seeds.rb +9 -0
  174. data/lib/generators/koi/active_record/active_record_generator.rb +43 -0
  175. data/lib/generators/koi/admin/USAGE +8 -0
  176. data/lib/generators/koi/admin/admin_generator.rb +20 -0
  177. data/lib/generators/koi/admin_controller/USAGE +17 -0
  178. data/lib/generators/koi/admin_controller/admin_controller_generator.rb +51 -0
  179. data/lib/generators/koi/admin_controller/templates/controller.rb.tt +81 -0
  180. data/lib/generators/koi/admin_controller/templates/controller_spec.rb.tt +135 -0
  181. data/lib/generators/koi/admin_route/admin_route_generator.rb +62 -0
  182. data/lib/generators/koi/admin_views/USAGE +12 -0
  183. data/lib/generators/koi/admin_views/admin_views_generator.rb +54 -0
  184. data/lib/generators/koi/admin_views/templates/_fields.html.erb.tt +3 -0
  185. data/lib/generators/koi/admin_views/templates/_record.html+row.erb.tt +10 -0
  186. data/lib/generators/koi/admin_views/templates/edit.html.erb.tt +12 -0
  187. data/lib/generators/koi/admin_views/templates/index.html.erb.tt +7 -0
  188. data/lib/generators/koi/admin_views/templates/new.html.erb.tt +11 -0
  189. data/lib/generators/koi/admin_views/templates/show.html.erb.tt +18 -0
  190. data/lib/govuk_design_system_formbuilder/concerns/file_element.rb +115 -0
  191. data/lib/govuk_design_system_formbuilder/elements/document.rb +59 -0
  192. data/lib/govuk_design_system_formbuilder/elements/image.rb +86 -0
  193. data/lib/katalyst/koi.rb +3 -0
  194. data/lib/koi/caching.rb +15 -0
  195. data/lib/koi/config.rb +11 -0
  196. data/lib/koi/engine.rb +40 -0
  197. data/lib/koi/form_builder.rb +76 -0
  198. data/lib/koi/menu/builder.rb +68 -0
  199. data/lib/koi/menu.rb +46 -0
  200. data/lib/koi/middleware/url_redirect.rb +44 -0
  201. data/lib/koi/release.rb +52 -0
  202. data/lib/koi/version.rb +5 -0
  203. data/lib/koi.rb +37 -0
  204. data/spec/factories/admins.rb +9 -0
  205. data/spec/factories/url_rewrites.rb +9 -0
  206. metadata +430 -0
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class UrlRewritesController < ApplicationController
5
+ before_action :set_url_rewrite, only: %i[show edit update destroy]
6
+
7
+ def index
8
+ collection = Collection.new.with_params(params).apply(UrlRewrite.strict_loading)
9
+ table = Koi::IndexTableComponent.new(collection:)
10
+
11
+ respond_to do |format|
12
+ format.turbo_stream { render(table) } if self_referred?
13
+ format.html { render :index, locals: { table:, collection: } }
14
+ end
15
+ end
16
+
17
+ def show
18
+ render :show, locals: { url_rewrite: @url_rewrite }
19
+ end
20
+
21
+ def new
22
+ @url_rewrite = UrlRewrite.new
23
+ render :new, locals: { url_rewrite: @url_rewrite }
24
+ end
25
+
26
+ def edit
27
+ render :edit, locals: { url_rewrite: @url_rewrite }
28
+ end
29
+
30
+ def create
31
+ @url_rewrite = UrlRewrite.new(url_rewrite_params)
32
+
33
+ if @url_rewrite.save
34
+ redirect_to admin_url_rewrite_path(@url_rewrite)
35
+ else
36
+ render :new, status: :unprocessable_entity, locals: { url_rewrite: @url_rewrite }
37
+ end
38
+ end
39
+
40
+ def update
41
+ @url_rewrite.attributes = url_rewrite_params
42
+
43
+ if @url_rewrite.save
44
+ redirect_to admin_url_rewrite_path(@url_rewrite)
45
+ else
46
+ render :edit, status: :unprocessable_entity, locals: { url_rewrite: @url_rewrite }
47
+ end
48
+ end
49
+
50
+ def destroy
51
+ @url_rewrite.destroy!
52
+
53
+ redirect_to admin_url_rewrites_path
54
+ end
55
+
56
+ private
57
+
58
+ def url_rewrite_params
59
+ params.require(:url_rewrite).permit(:from, :to, :active)
60
+ end
61
+
62
+ def set_url_rewrite
63
+ @url_rewrite = UrlRewrite.find(params[:id])
64
+ end
65
+
66
+ class Collection < Katalyst::Tables::Collection::Base
67
+ attribute :search, :string, default: ""
68
+ attribute :scope, :string, default: "active"
69
+
70
+ config.sorting = "from"
71
+ config.paginate = true
72
+
73
+ def filter
74
+ self.items = items.admin_search(search) if search.present?
75
+
76
+ self.items = case scope&.to_sym
77
+ when :active
78
+ items.where(active: true)
79
+ when :inactive
80
+ items.where(active: false)
81
+ else
82
+ items
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Controller
5
+ module HasAdminUsers
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ helper_method :admin_signed_in?
10
+ helper_method :current_admin_user
11
+ helper_method :current_admin
12
+ end
13
+
14
+ def admin_signed_in?
15
+ current_admin_user.present?
16
+ end
17
+
18
+ def current_admin_user
19
+ @current_admin_user ||= Admin::User.find(session[:admin_user_id]) if session[:admin_user_id].present?
20
+ end
21
+
22
+ # @deprecated Use current_admin_user instead
23
+ alias_method :current_admin, :current_admin_user
24
+
25
+ module Test
26
+ # Include in view specs to stub out the current admin user
27
+ module ViewHelper
28
+ extend ActiveSupport::Concern
29
+
30
+ included do
31
+ before do
32
+ view.singleton_class.module_eval do
33
+ def admin_signed_in?
34
+ current_admin_user.present?
35
+ end
36
+
37
+ def current_admin_user
38
+ respond_to?(:admin_user) ? admin_user : nil
39
+ end
40
+
41
+ alias_method :current_admin, :current_admin_user
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Controller
5
+ module HasWebauthn
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ helper_method :webauthn_auth_options
10
+ end
11
+
12
+ def webauthn_relying_party
13
+ @webauthn_relying_party ||=
14
+ WebAuthn::RelyingParty.new(
15
+ name: "Koi Admin",
16
+ origin: request.base_url,
17
+ )
18
+ end
19
+
20
+ def webauthn_auth_options
21
+ options = webauthn_relying_party.options_for_authentication(
22
+ allow: Admin::Credential.pluck(:external_id),
23
+ )
24
+ session[:authentication_challenge] = options.challenge
25
+
26
+ options
27
+ end
28
+
29
+ def webauthn_authenticate!
30
+ return if session_params[:response].blank?
31
+
32
+ webauthn_credential, stored_credential = webauthn_relying_party.verify_authentication(
33
+ JSON.parse(session_params[:response]),
34
+ session[:authentication_challenge],
35
+ ) do |credential|
36
+ Admin::Credential.find_by!(external_id: credential.id)
37
+ end
38
+
39
+ stored_credential.update!(sign_count: webauthn_credential.sign_count)
40
+
41
+ stored_credential.admin
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Controller
5
+ module IsAdminController
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def authenticate_local_admins(value)
10
+ Koi::Controller::IsAdminController.authenticate_local_admins = value
11
+ end
12
+ end
13
+
14
+ included do
15
+ include HasAdminUsers
16
+ include Katalyst::Tables::Backend
17
+ include Pagy::Backend
18
+
19
+ default_form_builder "Koi::FormBuilder"
20
+
21
+ helper Katalyst::GOVUK::Formbuilder::Frontend
22
+ helper Katalyst::Navigation::FrontendHelper
23
+ helper Katalyst::Tables::Frontend
24
+ helper Pagy::Frontend
25
+ helper IndexActionsHelper
26
+ helper :all
27
+
28
+ layout "koi/application"
29
+
30
+ before_action :authenticate_local_admin, if: -> { Koi::Controller::IsAdminController.authenticate_local_admins }
31
+ before_action :authenticate_admin, unless: :admin_signed_in?
32
+ end
33
+
34
+ class << self
35
+ attr_accessor :authenticate_local_admins
36
+ end
37
+
38
+ protected
39
+
40
+ def authenticate_local_admin
41
+ return if admin_signed_in? || !Rails.env.development?
42
+
43
+ session[:admin_user_id] =
44
+ Admin::User.where(email: %W[#{ENV['USER']}@katalyst.com.au admin@katalyst.com.au]).first&.id
45
+ end
46
+
47
+ def authenticate_admin
48
+ redirect_to new_admin_session_path, status: :temporary_redirect
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Content
5
+ module Editor
6
+ class Errors < Base
7
+ def build(**options)
8
+ turbo_frame_tag dom_id(container, :errors) do
9
+ form_builder.govuk_error_summary(**options)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def form_builder
16
+ Koi::FormBuilder.new(container.model_name.param_key, container, self, {})
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Katalyst
4
+ module Navigation
5
+ module Editor
6
+ class Errors < Base
7
+ def build(**options)
8
+ turbo_frame_tag dom_id(menu, :errors) do
9
+ form_builder.govuk_error_summary(**options)
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def form_builder
16
+ Koi::FormBuilder.new(menu.model_name.param_key, menu, self, {})
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module ApplicationHelper
5
+ include Katalyst::Tables::Frontend
6
+ end
7
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Naming/MethodName
4
+ module Koi
5
+ module DateHelper
6
+ # @deprecated
7
+ def date_format(date, format)
8
+ date.strftime format.gsub(/yyyy/, "%Y")
9
+ .gsub(/yy/, "%y")
10
+ .gsub(/Month/, "%B")
11
+ .gsub(/M/, "%b")
12
+ .gsub(/mm/, "%m")
13
+ .gsub(/m/, "%-m")
14
+ .gsub(/Day/, "%A")
15
+ .gsub(/D/, "%a")
16
+ .gsub(/dd/, "%d")
17
+ .gsub(/d/, "%-d")
18
+ end
19
+
20
+ # @deprecated
21
+ def date_Month_d_yyyy(date)
22
+ date.strftime "%B %-d, %Y"
23
+ end
24
+
25
+ # @deprecated
26
+ def date_d_Month_yyyy(date)
27
+ date.strftime "%-d %B %Y"
28
+ end
29
+
30
+ # @deprecated
31
+ def date_d_M_yy(date)
32
+ date.strftime "%-d %b %y"
33
+ end
34
+ end
35
+ end
36
+ # rubocop:enable Naming/MethodName
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module DefinitionListHelper
5
+ def definition_list(**options, &)
6
+ DefinitionListBuilder.new(self, options).render(&)
7
+ end
8
+ end
9
+
10
+ class DefinitionListBuilder
11
+ delegate_missing_to :@context
12
+
13
+ def initialize(context, options = {})
14
+ @context = context
15
+ @options = options
16
+ end
17
+
18
+ def render(&block)
19
+ tag.dl class: @options.delete(:class) do
20
+ concat(capture { yield self }) if block
21
+ end
22
+ end
23
+
24
+ def items_with(model:, attributes:, **options)
25
+ capture do
26
+ attributes.each do |attribute|
27
+ concat item(model, attribute, **options)
28
+ end
29
+ end
30
+ end
31
+
32
+ def item(object, attribute, **options, &)
33
+ Definition.new(@context, object, attribute, **@options, **options).render(&)
34
+ end
35
+
36
+ class Definition
37
+ attr_reader :object, :attribute, :options
38
+
39
+ delegate_missing_to :@context
40
+
41
+ def initialize(context, object, attribute, options = {})
42
+ @context = context
43
+ @object = object
44
+ @attribute = attribute
45
+ @options = options
46
+ end
47
+
48
+ def render(&)
49
+ return unless render?
50
+
51
+ term_tag + definition_tag(&)
52
+ end
53
+
54
+ private
55
+
56
+ def render?
57
+ !(options.fetch(:skip_blank, true) && attribute_value.blank? && attribute_value != false)
58
+ end
59
+
60
+ def term_tag
61
+ tag.dt(label)
62
+ end
63
+
64
+ def definition_tag(&block)
65
+ if block
66
+ tag.dd { yield attribute_value }
67
+ else
68
+ case attribute_value
69
+ when Array
70
+ tag.dd(attribute_value.join(", "))
71
+ when ActiveStorage::Attached::One
72
+ tag.dd(attribute_value.attached? ? link_to(attribute_value.filename, url_for(attribute_value)) : "")
73
+ when Date, Time, DateTime
74
+ tag.dd(l(attribute_value, format: :display))
75
+ when TrueClass, FalseClass
76
+ tag.dd(attribute_value ? "Yes" : "No")
77
+ else
78
+ tag.dd(attribute_value.to_s)
79
+ end
80
+ end
81
+ end
82
+
83
+ def label
84
+ options.dig(:label, :text) || object.class.human_attribute_name(attribute)
85
+ end
86
+
87
+ def attribute_value
88
+ @attribute_value ||= object.public_send(attribute)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module IndexActionsHelper
5
+ def koi_index_actions(search: false, create: false, &block)
6
+ IndexActionsBuilder.new(self, search:, create:).render(&block)
7
+ end
8
+ end
9
+
10
+ class IndexActionsBuilder
11
+ delegate_missing_to :@context
12
+
13
+ def initialize(context, search:, create:)
14
+ @context = context
15
+ @search = search
16
+ @create = create
17
+ end
18
+
19
+ def search?
20
+ !!@search
21
+ end
22
+
23
+ def create?
24
+ !!@create
25
+ end
26
+
27
+ def render(&)
28
+ tag.nav class: "index-actions", data: { controller: "index-actions", action: actions } do
29
+ form_with(**search_options,
30
+ data: { controller: "search",
31
+ turbo_stream: "",
32
+ action: <<~ACTIONS,
33
+ input->index-actions#update
34
+ change->index-actions#update
35
+ submit->index-actions#submit
36
+ ACTIONS
37
+ }) do |form|
38
+ concat(links(form, &))
39
+ concat(sort_input(form))
40
+ concat(search(form)) if create? || search?
41
+ end
42
+ end
43
+ end
44
+
45
+ def search(form)
46
+ tag.div class: "actions-group" do
47
+ concat(search_button(form)) if search?
48
+ concat(create_button(form)) if create?
49
+ concat(search_input(form)) if search?
50
+ end
51
+ end
52
+
53
+ def sort_input(form)
54
+ form.hidden_field(:sort, value: params[:sort], data: { index_actions_target: "sort" })
55
+ end
56
+
57
+ # Hidden button to trigger search, avoids triggering create instead.
58
+ def search_button(form)
59
+ form.submit("Search", hidden: "")
60
+ end
61
+
62
+ def create_button(_form)
63
+ tag.button(t("koi.labels.new", default: "New"),
64
+ type: :submit,
65
+ formaction: @create == true ? url_for(action: :new) : new_polymorphic_path(@create),
66
+ class: "button--primary",
67
+ data: { index_actions_target: "create" })
68
+ end
69
+
70
+ def search_input(form)
71
+ form.search_field(:search,
72
+ placeholder: t("koi.labels.search", default: "Search"),
73
+ value: params[:search],
74
+ data: { index_actions_target: "search" })
75
+ end
76
+
77
+ def links(form, &)
78
+ tag.div(class: "actions-group") do
79
+ yield(form) if block_given?
80
+ end
81
+ end
82
+
83
+ def actions
84
+ [
85
+ ("shortcut:cancel@document->index-actions#clear" if search?),
86
+ ("shortcut:create@document->index-actions#create" if create?),
87
+ ("shortcut:search@document->index-actions#search" if search?),
88
+ "shortcut:page-prev@document->index-actions#prevPage",
89
+ "shortcut:page-next@document->index-actions#nextPage",
90
+ ].compact.join(" ")
91
+ end
92
+
93
+ def search_options
94
+ options = { url: request.path, method: :get, scope: "" }
95
+ options.merge!(@search) if @search.is_a?(Hash)
96
+ options
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ class ApplicationMailer < ActionMailer::Base
5
+ default from: "support@katalyst.com.au"
6
+ layout "mailer"
7
+ end
8
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class Credential < ApplicationRecord
5
+ self.table_name = :admin_credentials
6
+
7
+ belongs_to :admin, class_name: "Admin::User", inverse_of: :credentials
8
+
9
+ validates :external_id, :public_key, :nickname, :sign_count, presence: true
10
+ validates :external_id, uniqueness: true
11
+ validates :sign_count,
12
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
13
+ end
14
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class User < ApplicationRecord
5
+ include Koi::Model::Archivable
6
+
7
+ def self.model_name
8
+ ActiveModel::Name.new(self, nil, "Admin")
9
+ end
10
+
11
+ has_secure_password :password
12
+
13
+ has_many :credentials, inverse_of: :admin, class_name: "Admin::Credential", dependent: :destroy
14
+
15
+ validates :name, :email, presence: true
16
+ validates :email, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
17
+
18
+ scope :alphabetical, -> { order(name: :asc) }
19
+
20
+ if "PgSearch::Model".safe_constantize
21
+ include PgSearch::Model
22
+
23
+ pg_search_scope :admin_search, against: %i[email name], using: { tsearch: { prefix: true } }
24
+ else
25
+ scope :admin_search, ->(query) do
26
+ where("email LIKE :query OR name LIKE :query", query: "%#{query}%")
27
+ end
28
+ end
29
+
30
+ # TODO(sfn) remove once Rails 7.1 is released
31
+ # https://edgeapi.rubyonrails.org/classes/ActiveRecord/SecurePassword/ClassMethods.html#method-i-authenticate_by
32
+ # rubocop:disable Metrics/PerceivedComplexity
33
+ def self.authenticate_by(attributes)
34
+ passwords, identifiers = attributes.to_h.partition do |name, _value|
35
+ !has_attribute?(name) && has_attribute?("#{name}_digest")
36
+ end.map(&:to_h)
37
+
38
+ raise ArgumentError, "One or more password arguments are required" if passwords.empty?
39
+ raise ArgumentError, "One or more finder arguments are required" if identifiers.empty?
40
+
41
+ if (record = find_by(identifiers))
42
+ record if passwords.count { |name, value| record.public_send(:"authenticate_#{name}", value) } == passwords.size
43
+ else
44
+ new(passwords)
45
+ nil
46
+ end
47
+ end
48
+
49
+ # rubocop:enable Metrics/PerceivedComplexity
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Model
5
+ module Archivable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ scope :not_archived, -> { where(archived_at: nil) }
10
+ scope :archived, -> { unscope(where: :archived_at).where.not(archived_at: nil) }
11
+ scope :with_archived, -> { unscope(where: :archived_at) }
12
+
13
+ default_scope { not_archived }
14
+
15
+ alias_method :archived?, :archived
16
+ end
17
+
18
+ # Returns true iff the record has been archived.
19
+ def archived
20
+ archived_at.present?
21
+ end
22
+
23
+ # Update archived status based on given boolean value.
24
+ def archived=(archived)
25
+ if ActiveRecord::Type::Boolean.new.cast(archived)
26
+ archive
27
+ else
28
+ restore
29
+ end
30
+ end
31
+
32
+ # Mark a record as archived. It will no longer appear in default queries.
33
+ def archive
34
+ self.archived_at = Time.current
35
+ end
36
+
37
+ # Archive a record immediately, without validation.
38
+ def archive!
39
+ archive
40
+ save!(validate: false)
41
+ end
42
+
43
+ # Mark a record as no longer archived. It will appear in default queries.
44
+ def restore
45
+ self.archived_at = nil
46
+ end
47
+
48
+ # Restore a record immediately, without validation.
49
+ def restore!
50
+ restore
51
+ save!(validate: false)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UrlRewrite < ApplicationRecord
4
+ validates :from, :to, presence: true
5
+ validates :from, format: { with: %r{\A/.*\z}, message: "should start with /" }
6
+
7
+ scope :active, -> { where(active: true) }
8
+ scope :alphabetical, -> { order(from: :asc) }
9
+
10
+ def to_s
11
+ "#{from} -> #{to}"
12
+ end
13
+
14
+ scope :admin_search, ->(query) do
15
+ where(arel_table[:from].matches("%#{query}%").or(arel_table[:to].matches("%#{query}%")))
16
+ end
17
+
18
+ def self.redirect_path_for(path)
19
+ active.find_by(from: path).try(:to)
20
+ end
21
+
22
+ def title
23
+ from.delete_prefix("/")
24
+ end
25
+ end
@@ -0,0 +1,4 @@
1
+ <%= row.cell :name do |cell| %>
2
+ <%= link_to cell.value, admin_admin_user_path(admin) %>
3
+ <% end %>
4
+ <%= row.cell :email %>