katalyst-koi 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
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 %>