katalyst-koi 4.15.1 → 4.17.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/koi/admin.css +1 -1
  3. data/app/assets/stylesheets/koi/components/_query.scss +4 -3
  4. data/app/assets/stylesheets/koi/layouts/_header.scss +18 -20
  5. data/app/assets/stylesheets/koi/pages/_login.scss +1 -1
  6. data/app/assets/stylesheets/koi/themes/_govuk.scss +37 -33
  7. data/app/assets/stylesheets/koi/utils/_typography.scss +20 -2
  8. data/app/controllers/admin/admin_users_controller.rb +1 -1
  9. data/app/controllers/admin/credentials_controller.rb +1 -1
  10. data/app/controllers/admin/otps_controller.rb +1 -1
  11. data/app/controllers/admin/sessions_controller.rb +2 -29
  12. data/app/controllers/admin/tokens_controller.rb +18 -44
  13. data/app/controllers/admin/url_rewrites_controller.rb +1 -1
  14. data/app/controllers/admin/well_knowns_controller.rb +71 -0
  15. data/app/controllers/concerns/koi/controller/has_webauthn.rb +6 -5
  16. data/app/controllers/concerns/koi/controller/records_authentication.rb +35 -0
  17. data/app/controllers/well_knowns_controller.rb +15 -0
  18. data/app/models/admin/user.rb +2 -0
  19. data/app/models/well_known.rb +25 -0
  20. data/app/views/admin/sessions/password.html.erb +3 -0
  21. data/app/views/admin/tokens/create.turbo_stream.erb +2 -0
  22. data/app/views/admin/tokens/show.html.erb +3 -1
  23. data/app/views/admin/well_knowns/_fields.html.erb +6 -0
  24. data/app/views/admin/well_knowns/edit.html.erb +12 -0
  25. data/app/views/admin/well_knowns/index.html.erb +15 -0
  26. data/app/views/admin/well_knowns/new.html.erb +11 -0
  27. data/app/views/admin/well_knowns/show.html.erb +19 -0
  28. data/config/routes.rb +4 -1
  29. data/db/migrate/20250204060748_create_well_knowns.rb +14 -0
  30. data/lib/generators/koi/admin_controller/templates/controller.rb.tt +2 -2
  31. data/lib/generators/koi/admin_route/admin_route_generator.rb +2 -1
  32. data/lib/generators/koi/admin_route/templates/initializer.rb.tt +1 -0
  33. data/spec/factories/well_knowns.rb +10 -0
  34. metadata +16 -10
  35. data/app/controllers/concerns/koi/controller/json_web_token.rb +0 -22
@@ -1,3 +1,4 @@
1
+ @use "sass:meta";
1
2
  @use "sass:string";
2
3
  @use "../utils/typography";
3
4
 
@@ -77,7 +78,7 @@ $text-icon-color: #888;
77
78
  }
78
79
 
79
80
  li.suggestion.attribute::before {
80
- background: url("data:image/svg+xml,%3Csvg width='14' height='16' viewBox='0 0 14 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13 3.1C13 4.2598 10.3137 5.2 7 5.2C3.68629 5.2 1 4.2598 1 3.1M13 3.1C13 1.9402 10.3137 1 7 1C3.68629 1 1 1.9402 1 3.1M13 3.1V12.9C13 14.062 10.3333 15 7 15C3.66667 15 1 14.062 1 12.9V3.1M13 8C13 9.162 10.3333 10.1 7 10.1C3.66667 10.1 1 9.162 1 8' stroke='%23#{string.slice(inspect($text-icon-color), 2)}' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A")
81
+ background: url("data:image/svg+xml,%3Csvg width='14' height='16' viewBox='0 0 14 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13 3.1C13 4.2598 10.3137 5.2 7 5.2C3.68629 5.2 1 4.2598 1 3.1M13 3.1C13 1.9402 10.3137 1 7 1C3.68629 1 1 1.9402 1 3.1M13 3.1V12.9C13 14.062 10.3333 15 7 15C3.66667 15 1 14.062 1 12.9V3.1M13 8C13 9.162 10.3333 10.1 7 10.1C3.66667 10.1 1 9.162 1 8' stroke='%23#{string.slice(meta.inspect($text-icon-color), 2)}' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A")
81
82
  no-repeat;
82
83
  top: 2px;
83
84
  }
@@ -85,13 +86,13 @@ $text-icon-color: #888;
85
86
  li.suggestion.database_value::before,
86
87
  li.suggestion.constant_value::before,
87
88
  li.suggestion.custom_value::before {
88
- background: url("data:image/svg+xml,%3Csvg width='14' height='16' viewBox='0 0 16 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M14.6111 5.16702L7.99998 8.99998M7.99998 8.99998L1.38886 5.16702M7.99998 8.99998L8 16.711M15 12.2943V5.70573C15 5.42761 15 5.28856 14.9607 5.16453C14.926 5.05481 14.8692 4.9541 14.7942 4.86912C14.7094 4.77307 14.5929 4.70553 14.3599 4.57047L8.60436 1.23354C8.38378 1.10565 8.27348 1.04171 8.15668 1.01664C8.05331 0.994453 7.94669 0.994453 7.84332 1.01664C7.72652 1.04171 7.61623 1.10565 7.39564 1.23354L1.64009 4.57047C1.40713 4.70554 1.29064 4.77307 1.20583 4.86912C1.13079 4.9541 1.074 5.05481 1.03927 5.16453C1 5.28856 1 5.42762 1 5.70573V12.2943C1 12.5724 1 12.7114 1.03927 12.8355C1.074 12.9452 1.13079 13.0459 1.20583 13.1309C1.29064 13.2269 1.40713 13.2945 1.64009 13.4295L7.39564 16.7665C7.61623 16.8943 7.72652 16.9583 7.84332 16.9834C7.94669 17.0055 8.05331 17.0055 8.15668 16.9834C8.27348 16.9583 8.38377 16.8943 8.60436 16.7665L14.3599 13.4295C14.5929 13.2945 14.7094 13.2269 14.7942 13.1309C14.8692 13.0459 14.926 12.9452 14.9607 12.8355C15 12.7114 15 12.5724 15 12.2943Z' stroke='%23#{string.slice(inspect($text-icon-color), 2)}' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A")
89
+ background: url("data:image/svg+xml,%3Csvg width='14' height='16' viewBox='0 0 16 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M14.6111 5.16702L7.99998 8.99998M7.99998 8.99998L1.38886 5.16702M7.99998 8.99998L8 16.711M15 12.2943V5.70573C15 5.42761 15 5.28856 14.9607 5.16453C14.926 5.05481 14.8692 4.9541 14.7942 4.86912C14.7094 4.77307 14.5929 4.70553 14.3599 4.57047L8.60436 1.23354C8.38378 1.10565 8.27348 1.04171 8.15668 1.01664C8.05331 0.994453 7.94669 0.994453 7.84332 1.01664C7.72652 1.04171 7.61623 1.10565 7.39564 1.23354L1.64009 4.57047C1.40713 4.70554 1.29064 4.77307 1.20583 4.86912C1.13079 4.9541 1.074 5.05481 1.03927 5.16453C1 5.28856 1 5.42762 1 5.70573V12.2943C1 12.5724 1 12.7114 1.03927 12.8355C1.074 12.9452 1.13079 13.0459 1.20583 13.1309C1.29064 13.2269 1.40713 13.2945 1.64009 13.4295L7.39564 16.7665C7.61623 16.8943 7.72652 16.9583 7.84332 16.9834C7.94669 17.0055 8.05331 17.0055 8.15668 16.9834C8.27348 16.9583 8.38377 16.8943 8.60436 16.7665L14.3599 13.4295C14.5929 13.2945 14.7094 13.2269 14.7942 13.1309C14.8692 13.0459 14.926 12.9452 14.9607 12.8355C15 12.7114 15 12.5724 15 12.2943Z' stroke='%23#{string.slice(meta.inspect($text-icon-color), 2)}' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A")
89
90
  no-repeat;
90
91
  top: 2px;
91
92
  }
92
93
 
93
94
  li.suggestion.search_value::before {
94
- background: url("data:image/svg+xml,%3Csvg width='14' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M21 21L15.0001 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z' stroke='%23#{string.slice(inspect($text-icon-color), 2)}' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A")
95
+ background: url("data:image/svg+xml,%3Csvg width='14' height='16' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M21 21L15.0001 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z' stroke='%23#{string.slice(meta.inspect($text-icon-color), 2)}' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E%0A")
95
96
  no-repeat;
96
97
  top: 2px;
97
98
  }
@@ -1,30 +1,34 @@
1
- @mixin list($separator, $space: 1rem) {
1
+ $inset: 2rem !default;
2
+
3
+ $title: 4rem;
4
+ $breadcrumbs: 3rem;
5
+ $actions: 3rem;
6
+ $height: $title + $breadcrumbs + $actions;
7
+
8
+ @mixin list($separator, $margin: 1em, $padding: 0.5em) {
2
9
  display: flex;
10
+ align-items: center;
3
11
  list-style-position: outside;
12
+ margin-inline: $inset;
4
13
 
5
14
  > * {
6
15
  display: list-item;
7
- margin-left: $space;
16
+ margin-inline-start: $margin;
17
+ padding-inline-start: $padding;
8
18
  }
9
19
 
10
20
  > *::marker {
11
- content: " " + $separator + " ";
21
+ content: $separator;
12
22
  color: rgba(0, 0, 0, 0.4);
13
23
  }
14
24
 
15
25
  > *:first-child {
16
26
  display: block;
17
- margin-left: 0;
27
+ margin-inline-start: 0;
28
+ padding-inline-start: 0;
18
29
  }
19
30
  }
20
31
 
21
- $inset: 2rem !default;
22
-
23
- $title: 4rem;
24
- $breadcrumbs: 3rem;
25
- $actions: 3rem;
26
- $height: $title + $breadcrumbs + $actions;
27
-
28
32
  %header {
29
33
  border-bottom: 1px solid #ececec;
30
34
  color: #b2b2b2;
@@ -44,20 +48,14 @@ $height: $title + $breadcrumbs + $actions;
44
48
 
45
49
  .breadcrumbs {
46
50
  grid-area: breadcrumbs;
47
- display: flex;
48
- align-items: center;
49
- margin: 0 $inset;
50
51
 
51
- @include list("› ", 1.2rem);
52
+ @include list("›");
52
53
  }
53
54
 
54
55
  .actions {
55
56
  grid-area: actions;
56
- display: flex;
57
- align-items: center;
58
- margin: 0 $inset;
57
+ gap: 0;
59
58
 
60
- @include list("|", 0.5rem);
61
- list-style-type: square;
59
+ @include list("|", $margin: 0.8em);
62
60
  }
63
61
  }
@@ -11,7 +11,7 @@
11
11
 
12
12
  .admin-login > main {
13
13
  flex: 1 1 auto;
14
- max-width: 20rem;
14
+ max-width: 24rem;
15
15
  background: white;
16
16
  border-radius: 0.25rem;
17
17
  height: unset;
@@ -1,42 +1,24 @@
1
1
  @use "../utils/typography" as *;
2
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),
3
+ @use "katalyst/govuk/formbuilder" with (
4
+ $govuk-font-family: "Inter",
5
+ $govuk-text-colour: #{var(--site-text-color)},
6
+ $govuk-typography-scale: (
7
+ 80: govuk-definition(h1),
8
+ 48: govuk-definition(h2),
9
+ 36: govuk-definition(h3),
10
+ 27: govuk-definition(h4),
11
+ 24: govuk-definition(h5),
12
+ 19: govuk-definition(h6),
13
+ 16: govuk-definition(paragraph),
14
+ 14: govuk-definition(small),
15
+ ),
16
+ $govuk-input-border-colour: #{var(--site-text-color)}
31
17
  );
32
18
 
33
- $govuk-input-border-colour: #{var(--site-text-color)};
34
-
35
- @import "katalyst/govuk/formbuilder";
36
-
37
19
  .govuk-input,
38
20
  .govuk-textarea {
39
- color: $govuk-text-colour;
21
+ color: var(--site-text-color);
40
22
  }
41
23
 
42
24
  .govuk-hint {
@@ -50,3 +32,25 @@ $govuk-input-border-colour: #{var(--site-text-color)};
50
32
  margin-left: -18px;
51
33
  padding-left: 13px;
52
34
  }
35
+
36
+ // add some koi styling to the govuk show/hide password button
37
+ [data-govuk-password-input-init] {
38
+ max-width: var(--text-width);
39
+
40
+ .govuk-password-input__wrapper {
41
+ gap: 0.5rem;
42
+ }
43
+
44
+ .govuk-button {
45
+ min-width: 4em;
46
+ background: none;
47
+ cursor: pointer;
48
+ border: 2px solid var(--site-text-color);
49
+ }
50
+
51
+ .govuk-button:focus-visible,
52
+ .govuk-button:hover {
53
+ color: var(--site-primary);
54
+ border-color: var(--site-primary);
55
+ }
56
+ }
@@ -1,10 +1,11 @@
1
+ @use "sass:map";
1
2
  @use "sass:math";
2
3
 
3
4
  $-font-sizes: () !default;
4
5
  $-line-heights: () !default;
5
6
 
6
7
  @function font-size($size, $breakpoint: null, $unit: rem) {
7
- $font-size: map-get($-font-sizes, $size);
8
+ $font-size: map.get($-font-sizes, $size);
8
9
 
9
10
  @if $unit == rem {
10
11
  @return $font-size;
@@ -14,7 +15,7 @@ $-line-heights: () !default;
14
15
  }
15
16
 
16
17
  @function line-height($size, $breakpoint: null, $unit: em) {
17
- $line-height: map-get($-line-heights, $size);
18
+ $line-height: map.get($-line-heights, $size);
18
19
 
19
20
  @if $unit == em {
20
21
  @return $line-height;
@@ -22,3 +23,20 @@ $-line-heights: () !default;
22
23
  @return math.div($line-height, 1em) * 16px;
23
24
  }
24
25
  }
26
+
27
+ @function govuk-definition($size) {
28
+ @return (
29
+ null: (
30
+ font-size: font-size($size, mobile, px),
31
+ line-height: line-height($size, mobile, px),
32
+ ),
33
+ tablet: (
34
+ font-size: font-size($size, tablet, px),
35
+ line-height: line-height($size, tablet, px),
36
+ ),
37
+ print: (
38
+ font-size: font-size($size, print, px),
39
+ line-height: line-height($size, print, px),
40
+ )
41
+ );
42
+ }
@@ -77,7 +77,7 @@ module Admin
77
77
  end
78
78
 
79
79
  def admin_user_params
80
- params.require(:admin).permit(:name, :email, :password, :archived)
80
+ params.expect(admin: %i[name email password archived])
81
81
  end
82
82
 
83
83
  class Collection < Admin::Collection
@@ -64,7 +64,7 @@ module Admin
64
64
  private
65
65
 
66
66
  def credential_params
67
- params.require(:admin_credential).permit(:nickname, :response)
67
+ params.expect(admin_credential: %i[nickname response])
68
68
  end
69
69
 
70
70
  def set_admin_user
@@ -36,7 +36,7 @@ module Admin
36
36
  private
37
37
 
38
38
  def otp_params
39
- params.require(:admin).permit(:otp_secret, :token)
39
+ params.expect(admin: %i[otp_secret token])
40
40
  end
41
41
 
42
42
  def set_admin_user
@@ -3,6 +3,7 @@
3
3
  module Admin
4
4
  class SessionsController < ApplicationController
5
5
  include Koi::Controller::HasWebauthn
6
+ include Koi::Controller::RecordsAuthentication
6
7
 
7
8
  before_action :redirect_authenticated, only: %i[new], if: :admin_signed_in?
8
9
  before_action :authenticate_local_admin, only: %i[new], if: :authenticate_local_admins?
@@ -102,35 +103,7 @@ module Admin
102
103
  end
103
104
 
104
105
  def session_params
105
- params.require(:admin).permit(:email, :password, :token, :response)
106
- end
107
-
108
- def update_last_sign_in(admin_user)
109
- return if admin_user.current_sign_in_at.blank?
110
-
111
- admin_user.last_sign_in_at = admin_user.current_sign_in_at
112
- admin_user.last_sign_in_ip = admin_user.current_sign_in_ip
113
- end
114
-
115
- def record_sign_in!(admin_user)
116
- update_last_sign_in(admin_user)
117
-
118
- admin_user.current_sign_in_at = Time.current
119
- admin_user.current_sign_in_ip = request.remote_ip
120
- admin_user.sign_in_count = admin_user.sign_in_count + 1
121
-
122
- admin_user.save!
123
- end
124
-
125
- def record_sign_out!(admin_user)
126
- return unless admin_user
127
-
128
- update_last_sign_in(admin_user)
129
-
130
- admin_user.current_sign_in_at = nil
131
- admin_user.current_sign_in_ip = nil
132
-
133
- admin_user.save!
106
+ params.expect(admin: %i[email password token response])
134
107
  end
135
108
  end
136
109
  end
@@ -2,66 +2,40 @@
2
2
 
3
3
  module Admin
4
4
  class TokensController < ApplicationController
5
- include Koi::Controller::JsonWebToken
5
+ include Koi::Controller::RecordsAuthentication
6
6
 
7
- before_action :set_admin, only: %i[create]
8
- before_action :set_token, only: %i[show update]
9
- before_action :invalid_token, only: %i[show update], unless: :token_valid?
7
+ before_action :set_admin_user, only: %i[create]
8
+
9
+ attr_reader :admin_user
10
10
 
11
11
  def show
12
- render locals: { admin: @admin, token: params[:token] }, layout: "koi/login"
12
+ if (@admin_user = Admin::User.find_by_token_for(:password_reset, params[:token]))
13
+ render locals: { admin_user:, token: params[:token] }, layout: "koi/login"
14
+ else
15
+ redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
16
+ end
13
17
  end
14
18
 
15
19
  def create
16
- token = encode_token(admin_id: @admin.id, exp: 30.minutes.from_now.to_i, iat: Time.current.to_i)
17
-
18
- render locals: { token: }
20
+ render locals: { token: admin_user.generate_token_for(:password_reset) }
19
21
  end
20
22
 
21
23
  def update
22
- sign_in_admin(@admin)
23
-
24
- redirect_to admin_admin_user_path(@admin), status: :see_other, notice: t("koi.auth.token_consumed")
25
- end
26
-
27
- private
24
+ if (@admin_user = Admin::User.find_by_token_for(:password_reset, params[:token]))
25
+ record_sign_in!(admin_user)
28
26
 
29
- def set_admin
30
- @admin = Admin::User.find(params[:admin_user_id])
31
- end
32
-
33
- def set_token
34
- @token = decode_token(params[:token])
35
-
36
- # constant time token validation requires that we always try to retrieve a user
37
- @admin = Admin::User.find_by(id: @token&.fetch(:admin_id) || "")
38
- end
27
+ session[:admin_user_id] = admin_user.id
39
28
 
40
- def token_valid?
41
- return false unless @token.present? && @admin.present?
42
-
43
- # Ensure that the user has not signed in since the token was generated
44
- if @admin.current_sign_in_at.present?
45
- @admin.current_sign_in_at.to_i < @token[:iat]
46
- elsif @admin.last_sign_in_at.present?
47
- @admin.last_sign_in_at.to_i < @token[:iat]
29
+ redirect_to admin_admin_user_path(admin_user), status: :see_other, notice: t("koi.auth.token_consumed")
48
30
  else
49
- true # first sign in
31
+ redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
50
32
  end
51
33
  end
52
34
 
53
- def invalid_token
54
- redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
55
- end
56
-
57
- def sign_in_admin(admin)
58
- admin.current_sign_in_at = Time.current
59
- admin.current_sign_in_ip = request.remote_ip
60
- admin.sign_in_count = 1
35
+ private
61
36
 
62
- # disable validations to allow saving without password or passkey credentials
63
- admin.save!(validate: false)
64
- session[:admin_user_id] = admin.id
37
+ def set_admin_user
38
+ @admin_user = Admin::User.find(params[:admin_user_id])
65
39
  end
66
40
  end
67
41
  end
@@ -52,7 +52,7 @@ module Admin
52
52
  private
53
53
 
54
54
  def url_rewrite_params
55
- params.require(:url_rewrite).permit(:from, :to, :status_code, :active)
55
+ params.expect(url_rewrite: %i[from to status_code active])
56
56
  end
57
57
 
58
58
  def set_url_rewrite
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class WellKnownsController < ApplicationController
5
+ before_action :set_well_known, only: %i[show edit update destroy]
6
+
7
+ def index
8
+ collection = Collection.new.with_params(params).apply(::WellKnown.strict_loading.all)
9
+
10
+ render locals: { collection: }
11
+ end
12
+
13
+ def show
14
+ render locals: { well_known: @well_known }
15
+ end
16
+
17
+ def new
18
+ render locals: { well_known: ::WellKnown.new }
19
+ end
20
+
21
+ def edit
22
+ render locals: { well_known: @well_known }
23
+ end
24
+
25
+ def create
26
+ @well_known = ::WellKnown.new(well_known_params)
27
+
28
+ if @well_known.save
29
+ redirect_to [:admin, @well_known], status: :see_other
30
+ else
31
+ render :new, locals: { well_known: @well_known }, status: :unprocessable_content
32
+ end
33
+ end
34
+
35
+ def update
36
+ if @well_known.update(well_known_params)
37
+ redirect_to action: :show, status: :see_other
38
+ else
39
+ render :edit, locals: { well_known: @well_known }, status: :unprocessable_content
40
+ end
41
+ end
42
+
43
+ def destroy
44
+ @well_known.destroy!
45
+
46
+ redirect_to action: :index, status: :see_other
47
+ end
48
+
49
+ private
50
+
51
+ # Only allow a list of trusted parameters through.
52
+ def well_known_params
53
+ params.expect(well_known: %i[name purpose content_type content])
54
+ end
55
+
56
+ # Use callbacks to share common setup or constraints between actions.
57
+ def set_well_known
58
+ @well_known = ::WellKnown.find(params[:id])
59
+ end
60
+
61
+ class Collection < Admin::Collection
62
+ config.sorting = :name
63
+ config.paginate = true
64
+
65
+ attribute :name, :string
66
+ attribute :purpose, :string
67
+ attribute :content_type, :string
68
+ attribute :content, :string
69
+ end
70
+ end
71
+ end
@@ -12,15 +12,14 @@ module Koi
12
12
  def webauthn_relying_party
13
13
  @webauthn_relying_party ||=
14
14
  WebAuthn::RelyingParty.new(
15
- name: Koi.config.admin_name,
16
- origin: request.base_url,
15
+ name: Koi.config.admin_name,
16
+ allowed_origins: [request.base_url],
17
17
  )
18
18
  end
19
19
 
20
20
  def webauthn_auth_options
21
- options = webauthn_relying_party.options_for_authentication(
22
- allow: Admin::Credential.pluck(:external_id),
23
- )
21
+ options = webauthn_relying_party.options_for_authentication
22
+
24
23
  session[:authentication_challenge] = options.challenge
25
24
 
26
25
  options
@@ -42,6 +41,8 @@ module Koi
42
41
  )
43
42
 
44
43
  stored_credential.admin
44
+ rescue ActiveRecord::RecordNotFound
45
+ false
45
46
  end
46
47
  end
47
48
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Controller
5
+ module RecordsAuthentication
6
+ def update_last_sign_in(admin_user)
7
+ return if admin_user.current_sign_in_at.blank?
8
+
9
+ admin_user.last_sign_in_at = admin_user.current_sign_in_at
10
+ admin_user.last_sign_in_ip = admin_user.current_sign_in_ip
11
+ end
12
+
13
+ def record_sign_in!(admin_user)
14
+ update_last_sign_in(admin_user)
15
+
16
+ admin_user.current_sign_in_at = Time.current
17
+ admin_user.current_sign_in_ip = request.remote_ip
18
+ admin_user.sign_in_count += 1
19
+
20
+ admin_user.save!
21
+ end
22
+
23
+ def record_sign_out!(admin_user)
24
+ return unless admin_user
25
+
26
+ update_last_sign_in(admin_user)
27
+
28
+ admin_user.current_sign_in_at = nil
29
+ admin_user.current_sign_in_ip = nil
30
+
31
+ admin_user.save!
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WellKnownsController < ApplicationController
4
+ before_action :set_well_known
5
+
6
+ def show
7
+ render renderable: @well_known
8
+ end
9
+
10
+ private
11
+
12
+ def set_well_known
13
+ @well_known = WellKnown.find_by!(name: params[:name])
14
+ end
15
+ end
@@ -12,6 +12,8 @@ module Admin
12
12
  # disable validations for password_digest
13
13
  has_secure_password validations: false
14
14
 
15
+ generates_token_for(:password_reset, expires_in: 30.minutes) { current_sign_in_at }
16
+
15
17
  has_many :credentials, inverse_of: :admin, class_name: "Admin::Credential", dependent: :destroy
16
18
 
17
19
  validates :name, :email, presence: true
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class WellKnown < ApplicationRecord
4
+ CONTENT_TYPES = {
5
+ text: "text/plain",
6
+ json: "application/json",
7
+ }.freeze
8
+
9
+ enum :content_type, CONTENT_TYPES
10
+
11
+ validates :name, :purpose, :content_type, presence: true
12
+ validates :name, uniqueness: { case_sensitive: true }
13
+
14
+ scope :admin_search, ->(query) do
15
+ where(arel_table[:name].matches("%#{query}%"))
16
+ end
17
+
18
+ def render_in(view_context)
19
+ view_context.render(plain: content)
20
+ end
21
+
22
+ def format
23
+ content_type.to_sym
24
+ end
25
+ end
@@ -8,4 +8,7 @@
8
8
  <%= form.govuk_password_field :password, autofocus: true, autocomplete: "off" %>
9
9
  <%= hidden_field_tag(:redirect, params[:redirect]) %>
10
10
  <%= form.admin_save "Next" %>
11
+
12
+ <%# init govuk js to provide the show/hide button %>
13
+ <%= govuk_formbuilder_init %>
11
14
  <% end %>
@@ -1,3 +1,5 @@
1
+ <%# locals: (token:) %>
2
+
1
3
  <%= turbo_stream.replace "invite" do %>
2
4
  <div class="action copy-to-clipboard govuk-input__wrapper" data-controller="clipboard" data-clipboard-supported-class="clipboard--supported">
3
5
  <%= text_field_tag :invite_link, admin_session_token_url(token), readonly: true, data: { clipboard_target: "source" } %>
@@ -1,8 +1,10 @@
1
+ <%# locals: (admin_user:, token:) %>
2
+
1
3
  <%= render "layouts/koi/navigation_header" %>
2
4
 
3
5
  <%= form_with(url: admin_session_token_path(token), method: :patch) do |form| %>
4
6
  <p>Welcome to Koi Admin</p>
5
- <%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %>
7
+ <%= render Koi::SummaryListComponent.new(model: admin_user, class: "item-table") do |builder| %>
6
8
  <%= builder.text :name %>
7
9
  <%= builder.text :email %>
8
10
  <% end %>
@@ -0,0 +1,6 @@
1
+ <%= form.govuk_text_field :name %>
2
+ <%= form.govuk_text_field :purpose %>
3
+ <%= form.govuk_collection_radio_buttons(
4
+ :content_type, WellKnown::CONTENT_TYPES, :first, :second, small: true
5
+ ) %>
6
+ <%= form.govuk_text_area :content %>
@@ -0,0 +1,12 @@
1
+ <% content_for :header do %>
2
+ <%= render(Koi::Header::EditComponent.new(resource: well_known)) %>
3
+ <% end %>
4
+
5
+ <%= form_with(model: well_known, url: admin_well_known_path(well_known)) do |form| %>
6
+ <%= render "fields", form: %>
7
+
8
+ <div class="actions">
9
+ <%= form.admin_save %>
10
+ <%= form.admin_delete %>
11
+ </div>
12
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <% content_for :header do %>
2
+ <%= render(Koi::Header::IndexComponent.new(model: WellKnown)) do |component| %>
3
+ <% component.with_action "New", new_admin_well_known_path %>
4
+ <% end %>
5
+ <% end %>
6
+
7
+ <%= table_query_with(collection:) %>
8
+
9
+ <%= table_with(collection:) do |row| %>
10
+ <% row.select %>
11
+ <% row.link :name %>
12
+ <% row.text :purpose %>
13
+ <% end %>
14
+
15
+ <%= table_pagination_with(collection:) %>