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,5 @@
1
+ @use "katalyst/content/editor";
2
+
3
+ [data-controller="content--editor--container"] {
4
+ --heading--h4: 1rem;
5
+ }
@@ -0,0 +1,52 @@
1
+ @use "../utils/typography" as *;
2
+
3
+ @function govuk-definition($size) {
4
+ @return (
5
+ null: (
6
+ font-size: font-size($size, mobile, px),
7
+ line-height: line-height($size, mobile, px),
8
+ ),
9
+ tablet: (
10
+ font-size: font-size($size, tablet, px),
11
+ line-height: line-height($size, tablet, px),
12
+ ),
13
+ print: (
14
+ font-size: font-size($size, print, px),
15
+ line-height: line-height($size, print, px),
16
+ )
17
+ );
18
+ }
19
+
20
+ $govuk-font-family: "Inter";
21
+ $govuk-text-colour: #{var(--site-text-color)};
22
+ $govuk-typography-scale: (
23
+ 80: govuk-definition(h1),
24
+ 48: govuk-definition(h2),
25
+ 36: govuk-definition(h3),
26
+ 27: govuk-definition(h4),
27
+ 24: govuk-definition(h5),
28
+ 19: govuk-definition(h6),
29
+ 16: govuk-definition(paragraph),
30
+ 14: govuk-definition(small),
31
+ );
32
+
33
+ $govuk-input-border-colour: #{var(--site-text-color)};
34
+
35
+ @import "katalyst/govuk/formbuilder";
36
+
37
+ .govuk-input,
38
+ .govuk-textarea {
39
+ color: $govuk-text-colour;
40
+ }
41
+
42
+ .govuk-hint {
43
+ max-width: var(--text-width);
44
+ }
45
+
46
+ // in the context of Koi admin forms, hang the error border in the gutter
47
+ // not a generally applicable style
48
+ .govuk-form-group--error {
49
+ position: relative;
50
+ margin-left: -18px;
51
+ padding-left: 13px;
52
+ }
@@ -0,0 +1,5 @@
1
+ @use "content";
2
+ @use "govuk";
3
+ @use "kpop";
4
+ @use "navigation";
5
+ @use "trix";
@@ -0,0 +1,5 @@
1
+ @use "katalyst/kpop";
2
+
3
+ .kpop-content {
4
+ padding: 1rem 1.5rem;
5
+ }
@@ -0,0 +1,5 @@
1
+ @use "katalyst/navigation/editor";
2
+
3
+ [data-controller="navigation--editor--menu"] {
4
+ --heading--h4: 1rem;
5
+ }
@@ -0,0 +1,32 @@
1
+ // navigation and content already load trix, ensure they are loaded first
2
+ @use "content";
3
+ @use "navigation";
4
+
5
+ // overrides for default trix styles to match our button styles
6
+ trix-toolbar {
7
+ .trix-button-group {
8
+ border: 1px solid var(--site-text-color);
9
+ border-radius: 0;
10
+ }
11
+
12
+ .trix-button {
13
+ color: var(--site-secondary);
14
+ border-bottom: none;
15
+ }
16
+
17
+ .trix-button:not(:first-child) {
18
+ border-left: 1px solid var(--site-text-color);
19
+ }
20
+
21
+ .trix-button.trix-active {
22
+ background-color: var(--site-secondary-light);
23
+ color: var(--site-on-secondary);
24
+ border-bottom: none;
25
+ }
26
+ }
27
+
28
+ // set a maximim width for the text editor and toolbar to match other inputs
29
+ trix-toolbar,
30
+ trix-editor {
31
+ max-width: var(--text-width);
32
+ }
@@ -0,0 +1,13 @@
1
+ // At a 16px base font size this will be 640px (to match govuk)
2
+ @mixin tablet-breakpoint {
3
+ @media screen and (max-width: 40rem) {
4
+ @content;
5
+ }
6
+ }
7
+
8
+ // At a 16px base font size this will be 1120px
9
+ @mixin laptop-breakpoint {
10
+ @media screen and (max-width: 70rem) {
11
+ @content;
12
+ }
13
+ }
@@ -0,0 +1,11 @@
1
+ .hidden,
2
+ [hidden],
3
+ [data-hide] {
4
+ display: none !important;
5
+ }
6
+
7
+ [data-collapsed] {
8
+ max-height: 0;
9
+ overflow: hidden;
10
+ margin-bottom: 0 !important;
11
+ }
@@ -0,0 +1,2 @@
1
+ @use "breakpoints";
2
+ @use "hide";
@@ -0,0 +1,24 @@
1
+ @use "sass:math";
2
+
3
+ $-font-sizes: () !default;
4
+ $-line-heights: () !default;
5
+
6
+ @function font-size($size, $breakpoint: null, $unit: rem) {
7
+ $font-size: map-get($-font-sizes, $size);
8
+
9
+ @if $unit == rem {
10
+ @return $font-size;
11
+ } @else if $unit == px {
12
+ @return math.div($font-size, 1rem) * 16px;
13
+ }
14
+ }
15
+
16
+ @function line-height($size, $breakpoint: null, $unit: em) {
17
+ $line-height: map-get($-line-heights, $size);
18
+
19
+ @if $unit == em {
20
+ @return $line-height;
21
+ } @else if $unit == px {
22
+ @return math.div($line-height, 1em) * 16px;
23
+ }
24
+ }
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Header
5
+ class EditComponent < ViewComponent::Base
6
+ attr_reader :model, :resource
7
+
8
+ delegate :with_breadcrumb, :with_action, to: :@header
9
+
10
+ def initialize(resource:, title: nil)
11
+ super
12
+
13
+ @resource = resource
14
+ @title = title
15
+
16
+ @header = HeaderComponent.new(title: self.title)
17
+ end
18
+
19
+ def call
20
+ render @header do |header|
21
+ # capture nested component
22
+ @header = header
23
+
24
+ # render block, if any (delegating slots to header)
25
+ content
26
+
27
+ # add our breadcrumbs and actions
28
+ add_index(header)
29
+ add_show(header)
30
+ end
31
+ end
32
+
33
+ def title
34
+ @title || "Edit #{resource.model_name.human.downcase}"
35
+ end
36
+
37
+ def resource_title
38
+ title = Koi.config.resource_name_candidates.reduce(nil) do |name, key|
39
+ name || resource.respond_to?(key) && resource.public_send(key)
40
+ end
41
+
42
+ title.presence || resource.model_name.human
43
+ end
44
+
45
+ def add_index(header)
46
+ header.with_breadcrumb(resource.model_name.human.pluralize, url_for(action: :index))
47
+ rescue ActionController::UrlGenerationError
48
+ nil
49
+ end
50
+
51
+ def add_show(header)
52
+ header.with_breadcrumb(resource_title, url_for(action: :show))
53
+ rescue ActionController::UrlGenerationError
54
+ nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Header
5
+ class IndexComponent < ViewComponent::Base
6
+ attr_reader :model
7
+
8
+ delegate :with_breadcrumb, :with_action, to: :@header
9
+
10
+ def initialize(model:, title: model.model_name.human.pluralize)
11
+ super
12
+
13
+ @header = HeaderComponent.new(title:)
14
+ @model = model
15
+ @title = title
16
+ end
17
+
18
+ def call
19
+ render @header.with_content(content || "")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Header
5
+ class NewComponent < ViewComponent::Base
6
+ attr_reader :model
7
+
8
+ delegate :with_breadcrumb, :with_action, to: :@header
9
+
10
+ def initialize(model:, title: nil)
11
+ super
12
+
13
+ @model = model
14
+ @title = title
15
+
16
+ @header = HeaderComponent.new(title: self.title)
17
+ end
18
+
19
+ def call
20
+ render @header do |header|
21
+ # render block, if any (delegating slots to header)
22
+ content
23
+
24
+ # add our breadcrumbs and actions
25
+ add_index(header)
26
+ end
27
+ end
28
+
29
+ def title
30
+ @title || "New #{model.model_name.human.downcase}"
31
+ end
32
+
33
+ def add_index(header)
34
+ header.with_breadcrumb(model.model_name.human.pluralize, url_for(action: :index))
35
+ rescue ActionController::UrlGenerationError
36
+ nil
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Header
5
+ class ShowComponent < ViewComponent::Base
6
+ attr_reader :resource
7
+
8
+ delegate :with_breadcrumb, :with_action, to: :@header
9
+
10
+ def initialize(resource:, title: nil)
11
+ super
12
+
13
+ @title = title
14
+ @resource = resource
15
+
16
+ @header = HeaderComponent.new(title: self.title)
17
+ end
18
+
19
+ def call
20
+ render @header do |header|
21
+ # render block, if any (delegating slots to header)
22
+ content
23
+
24
+ # add our breadcrumbs and actions
25
+ add_index(header)
26
+ add_edit(header)
27
+ end
28
+ end
29
+
30
+ def title
31
+ title = Koi.config.resource_name_candidates.reduce(@title) do |name, key|
32
+ name || resource.respond_to?(key) && resource.public_send(key)
33
+ end
34
+
35
+ title.presence || resource.model_name.human
36
+ end
37
+
38
+ def add_index(header)
39
+ header.with_breadcrumb(resource.model_name.human.pluralize, url_for(action: :index))
40
+ rescue ActionController::UrlGenerationError
41
+ nil
42
+ end
43
+
44
+ def add_edit(header)
45
+ header.with_action("Edit", url_for(action: :edit))
46
+ rescue ActionController::UrlGenerationError
47
+ nil
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,16 @@
1
+ <% content_for :title, @title %>
2
+ <header>
3
+ <h1 class="heading"><%= @title %></h1>
4
+
5
+ <div class="breadcrumbs" role="navigation" aria-label="Breadcrumbs">
6
+ <% breadcrumbs.each do |breadcrumb| %>
7
+ <%= breadcrumb %>
8
+ <% end %>
9
+ </div>
10
+
11
+ <div class="actions" role="navigation" aria-label="Related pages">
12
+ <% actions.each do |action| %>
13
+ <%= action %>
14
+ <% end %>
15
+ </div>
16
+ </header>
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ class HeaderComponent < ViewComponent::Base
5
+ renders_many :actions, "LinkComponent"
6
+ renders_many :breadcrumbs, "LinkComponent"
7
+
8
+ def initialize(title:)
9
+ super
10
+
11
+ @title = title
12
+ end
13
+
14
+ class LinkComponent < ViewComponent::Base
15
+ def initialize(name, path, **options)
16
+ super
17
+
18
+ @name = name
19
+ @path = path
20
+ @options = options
21
+ end
22
+
23
+ def call
24
+ link_to @name, @path, **@options
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ class IndexTableComponent < ViewComponent::Base
5
+ attr_reader :table, :pagination, :id, :pagy_id
6
+
7
+ def initialize(collection:, id: "index-table", **options)
8
+ super
9
+
10
+ @id = id
11
+ @pagy_id = "#{id}-pagination"
12
+ @table = Katalyst::Turbo::TableComponent.new(collection:, id:, class: "index-table", caption: true, **options)
13
+ @pagination = Katalyst::Turbo::PagyNavComponent.new(collection:, id: pagy_id) if collection.paginate?
14
+ end
15
+
16
+ def call
17
+ concat(table.render_in(view_context))
18
+ concat(pagination.render_in(view_context)) if pagination
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class AdminUsersController < ApplicationController
5
+ before_action :set_admin, only: %i[show edit update destroy]
6
+
7
+ attr_reader :admin
8
+
9
+ def index
10
+ collection = Collection.new.with_params(params).apply(Admin::User.strict_loading)
11
+ table = Koi::IndexTableComponent.new(collection:)
12
+
13
+ respond_to do |format|
14
+ format.turbo_stream { render(table) } if self_referred?
15
+ format.html { render :index, locals: { table:, collection: } }
16
+ end
17
+ end
18
+
19
+ def show
20
+ render :show, locals: { admin: }
21
+ end
22
+
23
+ def new
24
+ @admin = Admin::User.new
25
+
26
+ render :new, locals: { admin: }
27
+ end
28
+
29
+ def edit
30
+ render :edit, locals: { admin: }
31
+ end
32
+
33
+ def create
34
+ admin = Admin::User.new(admin_user_params)
35
+
36
+ if admin.save
37
+ redirect_to admin_admin_user_path(admin)
38
+ else
39
+ render :new, locals: { admin: }, status: :unprocessable_entity
40
+ end
41
+ end
42
+
43
+ def update
44
+ if admin.update(admin_user_params)
45
+ redirect_to action: :show
46
+ else
47
+ render :edit, locals: { admin: }, status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ def destroy
52
+ admin.destroy
53
+
54
+ redirect_to admin_admin_users_path
55
+ end
56
+
57
+ private
58
+
59
+ def set_admin
60
+ @admin = Admin::User.with_archived.find(params[:id])
61
+ end
62
+
63
+ def admin_user_params
64
+ params.require(:admin).permit(:name, :email, :password, :archived)
65
+ end
66
+
67
+ class Collection < Katalyst::Tables::Collection::Base
68
+ attribute :search, :string, default: ""
69
+ attribute :scope, :string, default: "active"
70
+
71
+ config.sorting = :name
72
+ config.paginate = true
73
+
74
+ def filter
75
+ self.items = items.admin_search(search) if search.present?
76
+
77
+ self.items = case scope&.to_sym
78
+ when :archived
79
+ items.archived
80
+ when :all
81
+ items.with_archived
82
+ else
83
+ items
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class ApplicationController < ActionController::Base
5
+ include Koi::Controller::IsAdminController
6
+
7
+ protect_from_forgery with: :exception
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class CachesController < ApplicationController
5
+ def destroy
6
+ Rails.logger.warn("[CACHE CLEAR] - Cleaning entire cache manually by #{current_admin} request")
7
+ Rails.cache.clear
8
+ redirect_back(fallback_location: admin_dashboard_path)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class CredentialsController < ApplicationController
5
+ include Koi::Controller::HasWebauthn
6
+
7
+ layout "kpop"
8
+
9
+ def new
10
+ unless current_admin.webauthn_id
11
+ current_admin.update!(webauthn_id: WebAuthn.generate_user_id)
12
+ end
13
+
14
+ options = webauthn_relying_party.options_for_registration(
15
+ user: {
16
+ id: current_admin.webauthn_id,
17
+ name: current_admin.email,
18
+ display_name: current_admin.to_s,
19
+ },
20
+ exclude: current_admin.credentials.map(&:external_id),
21
+ )
22
+
23
+ # Store the newly generated challenge somewhere so you can have it
24
+ # for the verification phase.
25
+ session[:creation_challenge] = options.challenge
26
+
27
+ render :new, locals: { options: }
28
+ end
29
+
30
+ def create
31
+ redirect_to(action: :new) if session[:creation_challenge].blank?
32
+
33
+ webauthn_credential = webauthn_relying_party.verify_registration(
34
+ JSON.parse(credential_params[:response]),
35
+ session.delete(:creation_challenge),
36
+ )
37
+
38
+ credential = current_admin.credentials.find_or_initialize_by(
39
+ external_id: webauthn_credential.id,
40
+ )
41
+
42
+ credential.update!(
43
+ nickname: credential_params[:nickname],
44
+ public_key: webauthn_credential.public_key,
45
+ sign_count: webauthn_credential.sign_count,
46
+ )
47
+
48
+ redirect_to admin_admin_user_path(current_admin), status: :see_other
49
+ end
50
+
51
+ def destroy
52
+ credential = current_admin.credentials.find(params[:id])
53
+ credential.destroy!
54
+
55
+ redirect_to admin_admin_user_path(current_admin), status: :see_other
56
+ end
57
+
58
+ private
59
+
60
+ def credential_params
61
+ params.require(:admin_credential).permit(:nickname, :response)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DashboardsController < ApplicationController
5
+ def show; end
6
+ end
7
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class SessionsController < ApplicationController
5
+ include Koi::Controller::HasWebauthn
6
+
7
+ skip_before_action :authenticate_admin, only: %i[new create]
8
+
9
+ layout "koi/login"
10
+
11
+ def new
12
+ return redirect_to admin_dashboard_path if admin_signed_in?
13
+
14
+ render :new, locals: { admin_user: Admin::User.new }
15
+ end
16
+
17
+ def create
18
+ if (admin_user = webauthn_authenticate!)
19
+ record_sign_in!(admin_user)
20
+
21
+ session[:admin_user_id] = admin_user.id
22
+
23
+ redirect_to admin_dashboard_path, notice: "You have been logged in"
24
+ elsif (admin_user = Admin::User.authenticate_by(session_params.slice(:email, :password)))
25
+ record_sign_in!(admin_user)
26
+
27
+ session[:admin_user_id] = admin_user.id
28
+
29
+ redirect_to admin_dashboard_path, notice: "You have been logged in"
30
+ else
31
+ admin_user = Admin::User.new(session_params.slice(:email, :password))
32
+ admin_user.errors.add(:email, "Invalid email or password")
33
+
34
+ render :new, status: :unprocessable_entity, locals: { admin_user: }
35
+ end
36
+ end
37
+
38
+ def destroy
39
+ record_sign_out!(current_admin_user)
40
+
41
+ session[:admin_user_id] = nil
42
+
43
+ redirect_to admin_dashboard_path, notice: "You have been logged out"
44
+ end
45
+
46
+ private
47
+
48
+ def session_params
49
+ params.require(:admin).permit(:email, :password, :response)
50
+ end
51
+
52
+ def update_last_sign_in(admin_user)
53
+ return if admin_user.current_sign_in_at.blank?
54
+
55
+ admin_user.last_sign_in_at = admin_user.current_sign_in_at
56
+ admin_user.last_sign_in_ip = admin_user.current_sign_in_ip
57
+ end
58
+
59
+ def record_sign_in!(admin_user)
60
+ update_last_sign_in(admin_user)
61
+
62
+ admin_user.current_sign_in_at = Time.current
63
+ admin_user.current_sign_in_ip = request.remote_ip
64
+ admin_user.sign_in_count = admin_user.sign_in_count + 1
65
+
66
+ admin_user.save!
67
+ end
68
+
69
+ def record_sign_out!(admin_user)
70
+ update_last_sign_in(admin_user)
71
+
72
+ admin_user.current_sign_in_at = nil
73
+ admin_user.current_sign_in_ip = nil
74
+
75
+ admin_user.save!
76
+ end
77
+ end
78
+ end