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,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