katalyst-koi 5.3.1 → 5.5.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -12
  3. data/app/assets/builds/katalyst/koi.esm.js +9 -19
  4. data/app/assets/builds/katalyst/koi.js +9 -19
  5. data/app/assets/builds/katalyst/koi.min.js +1 -1
  6. data/app/assets/builds/katalyst/koi.min.js.map +1 -1
  7. data/app/assets/stylesheets/koi/blocks/index.css +1 -0
  8. data/app/assets/stylesheets/koi/{login.css → blocks/roadblock.css} +8 -5
  9. data/app/controllers/admin/admin_users_controller.rb +3 -2
  10. data/app/controllers/admin/credentials_controller.rb +33 -51
  11. data/app/controllers/admin/device_authorizations_controller.rb +58 -0
  12. data/app/controllers/admin/device_tokens_controller.rb +18 -0
  13. data/app/controllers/admin/otps_controller.rb +6 -16
  14. data/app/controllers/admin/profiles_controller.rb +33 -0
  15. data/app/controllers/admin/sessions_controller.rb +12 -6
  16. data/app/controllers/admin/tokens_controller.rb +6 -2
  17. data/app/controllers/concerns/koi/controller/has_admin_users.rb +8 -11
  18. data/app/controllers/concerns/koi/controller/has_webauthn.rb +54 -9
  19. data/app/controllers/concerns/koi/controller.rb +7 -0
  20. data/app/javascript/koi/controllers/webauthn_authentication_controller.js +1 -1
  21. data/app/javascript/koi/controllers/webauthn_registration_controller.js +8 -18
  22. data/app/jobs/admin/device_authorizations_cleanup_job.rb +9 -0
  23. data/app/models/admin/credential.rb +4 -0
  24. data/app/models/admin/device_authorization.rb +113 -0
  25. data/app/models/admin/user.rb +1 -0
  26. data/app/models/koi/current.rb +8 -0
  27. data/app/views/admin/admin_users/edit.html.erb +3 -1
  28. data/app/views/admin/admin_users/show.html.erb +3 -0
  29. data/app/views/admin/credentials/_credentials.html.erb +8 -5
  30. data/app/views/admin/credentials/new.html.erb +32 -41
  31. data/app/views/admin/credentials/show.html.erb +19 -0
  32. data/app/views/admin/device_authorizations/show.html.erb +38 -0
  33. data/app/views/admin/otps/_form.html.erb +1 -1
  34. data/app/views/admin/{admin_users/_form.html+self.erb → profiles/_form.html.erb} +1 -1
  35. data/app/views/admin/{admin_users/edit.html+self.erb → profiles/edit.html.erb} +0 -1
  36. data/app/views/admin/{admin_users/show.html+self.erb → profiles/show.html.erb} +15 -10
  37. data/app/views/admin/sessions/new.html.erb +26 -27
  38. data/app/views/admin/sessions/otp.html.erb +13 -5
  39. data/app/views/admin/sessions/password.html.erb +16 -8
  40. data/app/views/admin/tokens/show.html.erb +12 -8
  41. data/app/views/layouts/koi/_application_navigation.html.erb +13 -9
  42. data/app/views/layouts/koi/application.html.erb +19 -10
  43. data/config/locales/koi.en.yml +0 -1
  44. data/config/routes.rb +17 -9
  45. data/db/migrate/20260413014834_create_admin_device_authorizations.rb +20 -0
  46. data/lib/generators/koi/helpers/resource_helpers.rb +1 -1
  47. data/lib/koi/config.rb +2 -1
  48. data/lib/koi/engine.rb +1 -0
  49. data/lib/koi/middleware/admin_authentication.rb +54 -10
  50. data/spec/factories/admin_device_authorizations.rb +29 -0
  51. metadata +30 -10
  52. data/app/views/admin/credentials/_credentials.html+self.erb +0 -12
  53. data/app/views/admin/credentials/create.turbo_stream.erb +0 -5
  54. data/app/views/admin/credentials/destroy.turbo_stream.erb +0 -5
  55. data/app/views/layouts/koi/login.html.erb +0 -50
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class ProfilesController < ApplicationController
5
+ include Koi::Controller::HasWebauthn
6
+
7
+ before_action :requires_session_authentication!
8
+
9
+ alias_method :admin_user, :current_admin
10
+
11
+ def show
12
+ render locals: { admin_user: }
13
+ end
14
+
15
+ def edit
16
+ render :edit, locals: { admin_user: }
17
+ end
18
+
19
+ def update
20
+ if admin_user.update(profile_params)
21
+ redirect_to admin_profile_path, status: :see_other
22
+ else
23
+ render :edit, locals: { admin_user: }, status: :unprocessable_content
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def profile_params
30
+ params.expect(admin_user: %i[name email password])
31
+ end
32
+ end
33
+ end
@@ -8,10 +8,16 @@ module Admin
8
8
  before_action :redirect_authenticated, only: %i[new], if: :admin_signed_in?
9
9
  before_action :authenticate_local_admin, only: %i[new], if: -> { Koi.config.authenticate_local_admins }
10
10
 
11
- layout "koi/login"
11
+ attr_reader :admin_user
12
12
 
13
13
  def new
14
- render locals: { admin_user: Admin::User.new }
14
+ @admin_user = Admin::User.new
15
+
16
+ if (message = flash.alert || flash.notice)
17
+ admin_user.errors.add(:email, message)
18
+ end
19
+
20
+ render locals: { admin_user: }
15
21
  end
16
22
 
17
23
  def create
@@ -35,7 +41,7 @@ module Admin
35
41
  end
36
42
 
37
43
  def destroy
38
- record_sign_out!(current_admin_user)
44
+ record_sign_out!(Koi::Current.admin_user)
39
45
 
40
46
  session[:admin_user_id] = nil
41
47
 
@@ -80,7 +86,7 @@ module Admin
80
86
  end
81
87
 
82
88
  def create_session_with_webauthn
83
- if (admin_user = webauthn_authenticate!)
89
+ if (admin_user = webauthn_authenticate!(session_params[:response]))
84
90
  admin_sign_in(admin_user)
85
91
  else
86
92
  admin_user = Admin::User.new
@@ -97,11 +103,11 @@ module Admin
97
103
  def authenticate_local_admin
98
104
  return if admin_signed_in? || !Rails.env.development?
99
105
 
100
- @current_admin_user = Admin::User.find_by(email: "#{ENV.fetch('USER', nil)}@katalyst.com.au")
106
+ Koi::Current.admin_user = Admin::User.find_by(email: "#{ENV.fetch('USER', nil)}@katalyst.com.au")
101
107
 
102
108
  return unless admin_signed_in?
103
109
 
104
- session[:admin_user_id] = current_admin_user.id
110
+ session[:admin_user_id] = Koi::Current.admin_user.id
105
111
 
106
112
  flash.delete(:redirect) if (redirect = flash[:redirect])
107
113
 
@@ -10,7 +10,7 @@ module Admin
10
10
 
11
11
  def show
12
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"
13
+ render locals: { admin_user:, token: params[:token] }
14
14
  else
15
15
  redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
16
16
  end
@@ -26,7 +26,11 @@ module Admin
26
26
 
27
27
  session[:admin_user_id] = admin_user.id
28
28
 
29
- redirect_to admin_admin_user_path(admin_user), status: :see_other, notice: t("koi.auth.token_consumed")
29
+ if admin_user.credentials.any?
30
+ redirect_to(admin_root_path, status: :see_other)
31
+ else
32
+ redirect_to(new_admin_profile_credential_path, status: :see_other)
33
+ end
30
34
  else
31
35
  redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
32
36
  end
@@ -14,23 +14,20 @@ module Koi
14
14
  end
15
15
 
16
16
  def admin_signed_in?
17
- current_admin_user.present?
18
- rescue ActiveRecord::RecordNotFound
19
- false
17
+ Koi::Current.admin_user.present?
20
18
  end
21
19
 
22
20
  def current_admin_user
23
- return @current_admin_user if instance_variable_defined?(:@current_admin_user)
24
- return @current_admin_user = nil unless session.has_key?(:admin_user_id)
25
-
26
- @current_admin_user = Admin::User.find(session[:admin_user_id])
27
- ensure
28
- session.delete(:admin_user_id) unless @current_admin_user
21
+ Koi::Current.admin_user
29
22
  end
30
23
 
31
24
  # @deprecated Use current_admin_user instead
32
25
  alias_method :current_admin, :current_admin_user
33
26
 
27
+ def requires_session_authentication!
28
+ head(:forbidden) if session[:admin_user_id].blank?
29
+ end
30
+
34
31
  module Test
35
32
  # Include in view specs to stub out the current admin user
36
33
  module ViewHelper
@@ -40,11 +37,11 @@ module Koi
40
37
  before do
41
38
  view.singleton_class.module_eval do
42
39
  def admin_signed_in?
43
- current_admin_user.present?
40
+ Koi::Current.admin_user.present?
44
41
  end
45
42
 
46
43
  def current_admin_user
47
- respond_to?(:admin_user) ? admin_user : nil
44
+ Koi::Current.admin_user = (respond_to?(:admin_user) ? admin_user : nil)
48
45
  end
49
46
 
50
47
  alias_method :current_admin, :current_admin_user
@@ -6,7 +6,7 @@ module Koi
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
- helper_method :webauthn_auth_options
9
+ helper Helper
10
10
  end
11
11
 
12
12
  def webauthn_relying_party
@@ -17,20 +17,41 @@ module Koi
17
17
  )
18
18
  end
19
19
 
20
- def webauthn_auth_options
21
- options = webauthn_relying_party.options_for_authentication
20
+ module Helper
21
+ def webauthn_authentication_options_value
22
+ options = controller.webauthn_relying_party.options_for_authentication
22
23
 
23
- session[:authentication_challenge] = options.challenge
24
+ session[:authentication_challenge] = options.challenge
24
25
 
25
- options
26
+ options
27
+ end
28
+
29
+ def webauthn_registration_options_value
30
+ user = Koi::Current.admin_user.tap do |u|
31
+ u.update!(webauthn_id: WebAuthn.generate_user_id) unless u.webauthn_id
32
+ end
33
+
34
+ options = controller.webauthn_relying_party.options_for_registration(
35
+ user: {
36
+ id: user.webauthn_id,
37
+ name: user.email,
38
+ display_name: user.name,
39
+ },
40
+ exclude: user.credentials.pluck(:external_id),
41
+ )
42
+
43
+ session[:registration_challenge] = options.challenge
44
+
45
+ options
46
+ end
26
47
  end
27
48
 
28
- def webauthn_authenticate!
29
- return if session_params[:response].blank?
49
+ def webauthn_authenticate!(response)
50
+ return if response.blank?
30
51
 
31
52
  webauthn_credential, stored_credential = webauthn_relying_party.verify_authentication(
32
- JSON.parse(session_params[:response]),
33
- session[:authentication_challenge],
53
+ JSON.parse(response),
54
+ session.delete(:authentication_challenge),
34
55
  ) do |credential|
35
56
  Admin::Credential.find_by!(external_id: credential.id)
36
57
  end
@@ -44,6 +65,30 @@ module Koi
44
65
  rescue ActiveRecord::RecordNotFound, WebAuthn::VerificationError
45
66
  false
46
67
  end
68
+
69
+ def webauthn_register!(response)
70
+ return if response.blank?
71
+
72
+ webauthn_credential = webauthn_relying_party.verify_registration(
73
+ JSON.parse(response),
74
+ session.delete(:registration_challenge),
75
+ )
76
+
77
+ Koi::Current
78
+ .admin_user
79
+ .credentials
80
+ .create_with(nickname: webauthn_nickname,
81
+ public_key: webauthn_credential.public_key,
82
+ sign_count: webauthn_credential.sign_count)
83
+ .create_or_find_by!(
84
+ external_id: webauthn_credential.id,
85
+ )
86
+ end
87
+
88
+ def webauthn_nickname
89
+ user_agent = UserAgent.parse(request.user_agent)
90
+ "#{user_agent.browser} (#{user_agent.platform})"
91
+ end
47
92
  end
48
93
  end
49
94
  end
@@ -38,6 +38,13 @@ module Koi
38
38
  layout -> { turbo_frame_request? ? "koi/frame" : "koi/application" }
39
39
 
40
40
  protect_from_forgery with: :exception
41
+ skip_forgery_protection if: :bearer_token_request?
42
+ end
43
+
44
+ private
45
+
46
+ def bearer_token_request?
47
+ request.authorization.to_s.match?(/\ABearer .+\z/)
41
48
  end
42
49
  end
43
50
  end
@@ -17,7 +17,7 @@ export default class WebauthnAuthenticationController extends Controller {
17
17
  get options() {
18
18
  return {
19
19
  publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(
20
- this.optionsValue.publicKey,
20
+ this.optionsValue,
21
21
  ),
22
22
  };
23
23
  }
@@ -1,39 +1,29 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  export default class WebauthnRegistrationController extends Controller {
4
+ static targets = ["response"];
4
5
  static values = {
5
6
  options: Object,
6
- response: Object,
7
7
  };
8
- static targets = ["intro", "nickname", "response"];
9
8
 
10
9
  submit(e) {
11
- if (
12
- this.responseTarget.value === "" &&
13
- e.submitter.formMethod !== "dialog"
14
- ) {
15
- e.preventDefault();
16
- this.createCredential().then();
17
- }
10
+ if (this.responseTarget.value) return;
11
+
12
+ e.preventDefault();
13
+ this.createCredential().then(() => {
14
+ e.target.submit();
15
+ });
18
16
  }
19
17
 
20
18
  async createCredential() {
21
19
  const credential = await navigator.credentials.create(this.options);
22
-
23
- this.responseValue = credential.toJSON();
24
20
  this.responseTarget.value = JSON.stringify(credential.toJSON());
25
21
  }
26
22
 
27
- responseValueChanged(response) {
28
- const responsePresent = response !== "";
29
- this.introTarget.toggleAttribute("hidden", responsePresent);
30
- this.nicknameTarget.toggleAttribute("hidden", !responsePresent);
31
- }
32
-
33
23
  get options() {
34
24
  return {
35
25
  publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(
36
- this.optionsValue.publicKey,
26
+ this.optionsValue,
37
27
  ),
38
28
  };
39
29
  }
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DeviceAuthorizationsCleanupJob < Koi::ApplicationJob
5
+ def perform
6
+ Admin::DeviceAuthorization.where(created_at: ...7.days.ago).delete_all
7
+ end
8
+ end
9
+ end
@@ -10,5 +10,9 @@ module Admin
10
10
  validates :external_id, uniqueness: true
11
11
  validates :sign_count,
12
12
  numericality: { only_integer: true, greater_than_or_equal_to: 0 }
13
+
14
+ def to_s
15
+ nickname
16
+ end
13
17
  end
14
18
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DeviceAuthorization < ApplicationRecord
5
+ EXPIRES_IN = 10.minutes
6
+
7
+ class TokenError < StandardError
8
+ attr_reader :code
9
+
10
+ def initialize(code)
11
+ @code = code
12
+ super
13
+ end
14
+ end
15
+
16
+ self.table_name = :admin_device_authorizations
17
+
18
+ belongs_to :admin_user, class_name: "Admin::User", optional: true
19
+
20
+ enum :status, %w[pending approved denied consumed].index_with(&:to_s)
21
+
22
+ validates :device_code_digest, presence: true, uniqueness: true
23
+ validates :request_expires_at, presence: true
24
+ validates :status, presence: true, inclusion: { in: statuses.values }
25
+ validates :user_code, presence: true, uniqueness: true
26
+
27
+ def self.issue!(requested_ip:, user_agent:)
28
+ device_code = SecureRandom.urlsafe_base64(32)
29
+
30
+ device_authorization = create!(
31
+ device_code_digest: digest(device_code),
32
+ user_code: generate_user_code,
33
+ request_expires_at: EXPIRES_IN.from_now,
34
+ requested_ip:,
35
+ user_agent:,
36
+ )
37
+
38
+ [device_authorization, device_code]
39
+ end
40
+
41
+ def self.digest(device_code)
42
+ Digest::SHA256.hexdigest(device_code)
43
+ end
44
+
45
+ def self.generate_user_code
46
+ "#{SecureRandom.alphanumeric(4).upcase}-#{SecureRandom.alphanumeric(4).upcase}"
47
+ end
48
+
49
+ def self.issue_access_token!(device_code:, token_expires_in: 12.hours)
50
+ device_authorization = find_by(device_code_digest: digest(device_code.to_s))
51
+ raise TokenError.new("invalid_grant") unless device_authorization
52
+
53
+ device_authorization.with_lock do
54
+ device_authorization.reload
55
+
56
+ if (error = device_authorization.token_error)
57
+ raise TokenError.new(error)
58
+ end
59
+
60
+ access_token = device_authorization.admin_user.generate_token_for(:api_access)
61
+ device_authorization.consume!(token_expires_in:)
62
+
63
+ {
64
+ access_token:,
65
+ token_type: "Bearer",
66
+ expires_in: token_expires_in.to_i,
67
+ }
68
+ end
69
+ end
70
+
71
+ def expired?
72
+ request_expires_at <= Time.current
73
+ end
74
+
75
+ def issuable?
76
+ approved? && !expired?
77
+ end
78
+
79
+ def token_error
80
+ return "authorization_pending" if pending?
81
+ return "access_denied" if denied?
82
+
83
+ "invalid_grant" if consumed? || expired?
84
+ end
85
+
86
+ def consume!(token_expires_in:)
87
+ update!(
88
+ status: "consumed",
89
+ consumed_at: Time.current,
90
+ token_expires_at: token_expires_in.from_now,
91
+ )
92
+ end
93
+
94
+ def approve!(admin_user:)
95
+ update!(
96
+ status: "approved",
97
+ approved_at: Time.current,
98
+ admin_user:,
99
+ )
100
+ end
101
+
102
+ def deny!(admin_user:)
103
+ update!(
104
+ status: "denied",
105
+ admin_user:,
106
+ )
107
+ end
108
+
109
+ def actionable?
110
+ pending? && !expired?
111
+ end
112
+ end
113
+ end
@@ -14,6 +14,7 @@ module Admin
14
14
  # disable validations for password_digest
15
15
  has_secure_password validations: false
16
16
 
17
+ generates_token_for(:api_access, expires_in: 12.hours) { current_sign_in_at }
17
18
  generates_token_for(:password_reset, expires_in: 30.minutes) { current_sign_in_at }
18
19
 
19
20
  has_many :credentials, inverse_of: :admin, class_name: "Admin::Credential", dependent: :destroy
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ # @return [Admin::User]
6
+ attribute :admin_user
7
+ end
8
+ end
@@ -9,7 +9,9 @@
9
9
  <h1>Edit admin user</h1>
10
10
 
11
11
  <%= actions_list do %>
12
- <li><%= link_to_archive_or_delete(admin_user) %></li>
12
+ <% unless admin_user == current_admin %>
13
+ <li><%= link_to_archive_or_delete(admin_user) %></li>
14
+ <% end %>
13
15
  <% end %>
14
16
  <% end %>
15
17
 
@@ -8,6 +8,9 @@
8
8
  <h1><%= admin_user %></h1>
9
9
 
10
10
  <%= actions_list do %>
11
+ <% if admin_user == current_admin %>
12
+ <li><%= link_to("Profile", admin_profile_path) %></li>
13
+ <% end %>
11
14
  <li><%= link_to("Edit", edit_admin_admin_user_path(admin_user)) %></li>
12
15
  <% end %>
13
16
  <% end %>
@@ -1,8 +1,11 @@
1
- <%# locals: (admin_user:) %>
1
+ <%# locals: (admin_user:, collection: admin_user.credentials) %>
2
2
 
3
- <%= table_with(id: dom_id(admin_user, :credentials), collection: admin_user.credentials) do |t, c| %>
4
- <% t.text :nickname, label: "Name" %>
5
- <% t.date :updated_at, label: "Last use" do |date| %>
6
- <%= date unless c.created_at == c.updated_at %>
3
+ <%= table_with(id: dom_id(admin_user, :credentials), collection:) do |row, credential| %>
4
+ <% row.text(:nickname, label: "Name") do |cell| %>
5
+ <%= link_to(cell, admin_credential_path(credential)) %>
6
+ <% end %>
7
+ <% row.date(:created_at, label: "Created") %>
8
+ <% row.date(:updated_at, label: "Last use") do |date| %>
9
+ <%= date unless credential.created_at == credential.updated_at %>
7
10
  <% end %>
8
11
  <% end %>
@@ -1,43 +1,34 @@
1
- <%# locals: (admin_user:, credential:, options:) %>
1
+ <%# locals: (admin_user:) %>
2
2
 
3
- <%= koi_modal_tag("edit", title: "New passkey") do %>
4
- <%= koi_modal_header(title: "New passkey", form_id: dom_id(credential, :form)) %>
5
- <main>
6
- <%= form_with(model: credential,
7
- url: admin_admin_user_credentials_path(admin_user),
8
- id: dom_id(credential, :form),
9
- class: "flow prose",
10
- data: {
11
- controller: "webauthn-registration",
12
- action: "submit->webauthn-registration#submit",
13
- webauthn_registration_options_value: { publicKey: options },
14
- }) do |form| %>
15
- <%= form.hidden_field :response, data: { webauthn_registration_target: "response" } %>
16
- <section class="flow prose" data-webauthn-registration-target="intro">
17
- <p>
18
- Passkeys are secure secrets that are stored by your device.
19
- You will need the device where your passkey is stored to log in.
20
- </p>
21
- <p>
22
- Unlike a password, your password doesn't get sent to the server when you log
23
- in and can't be stolen in a data breach. When you log in with a passkey,
24
- your operating system will prompt you for permission to use the passkey
25
- secret to authenticate the login attempt.
26
- </p>
27
- <p>
28
- We recommend that you store your passkey on your phone or cloud account.
29
- Depending on your browser, you may need to choose "more options" to see
30
- a QR code that you can scan with your phone.
31
- </p>
32
- </section>
33
- <section class="flow" data-webauthn-registration-target="nickname" hidden>
34
- <%= form.govuk_text_field :nickname, label: { text: "Passkey name" } do %>
35
- Enter a name for this passkey to help you distinguish it from other passkeys you may have for this site.
36
- <br>
37
- Example: My Phone, Chrome, iCloud, 1Password
38
- <% end %>
39
- </section>
40
- <% end %>
41
- </main>
42
- <%= koi_modal_footer("Next", nil, form_id: dom_id(credential, :form)) %>
3
+ <%= content_for(:roadblock) do %>
4
+ <header>
5
+ <icon aria-hidden="true" class="icon" data-icon="fingerprint"></icon>
6
+ <h1>Create a passkey</h1>
7
+ </header>
8
+ <p>
9
+ Passkeys are secure secrets that are stored by your device.
10
+ You will need the device where your passkey is stored to log in.
11
+ </p>
12
+ <p>
13
+ Unlike a password, your password doesn't get sent to the server when you log
14
+ in and can't be stolen in a data breach. When you log in with a passkey,
15
+ your operating system will prompt you for permission to use the passkey
16
+ secret to authenticate the login attempt.
17
+ </p>
18
+ <p>
19
+ We recommend that you store your passkey on your phone or cloud account.
20
+ Depending on your browser, you may need to choose "more options" to see
21
+ a QR code that you can scan with your phone.
22
+ </p>
23
+
24
+ <%= form_with(model: Admin::Credential.new,
25
+ url: admin_profile_credentials_path,
26
+ data: {
27
+ controller: "webauthn-registration",
28
+ action: "submit->webauthn-registration#submit",
29
+ webauthn_registration_options_value:,
30
+ }) do |form| %>
31
+ <%= form.hidden_field :response, data: { webauthn_registration_target: "response" } %>
32
+ <%= form.button("Next", type: :submit, class: "button") %>
33
+ <% end %>
43
34
  <% end %>
@@ -0,0 +1,19 @@
1
+ <%# locals: (credential:) %>
2
+
3
+ <% content_for(:header) do %>
4
+ <%= breadcrumb_list do %>
5
+ <li><%= link_to(credential.admin, admin_profile_path) %></li>
6
+ <% end %>
7
+
8
+ <h1>Passkey</h1>
9
+
10
+ <%= actions_list do %>
11
+ <li><%= link_to_delete(credential, url: admin_credential_path(credential)) %></li>
12
+ <% end %>
13
+ <% end %>
14
+
15
+ <%= form_with(model: credential, url: admin_credential_path(credential)) do |form| %>
16
+ <%= form.govuk_text_field(:nickname) %>
17
+
18
+ <%= form.button(type: :submit, class: "button") %>
19
+ <% end %>
@@ -0,0 +1,38 @@
1
+ <%# locals: (device_authorization:) %>
2
+
3
+ <%= content_for(:roadblock) do %>
4
+ <header>
5
+ <icon aria-hidden="true" class="icon" data-icon="koi">&nbsp;</icon>
6
+ <h1>API access request</h1>
7
+ </header>
8
+
9
+ <% if device_authorization.pending? && !device_authorization.expired? %>
10
+ <p>
11
+ A device is requesting a short-lived API token.
12
+ </p>
13
+ <dl>
14
+ <dt>Code</dt><dd><%= device_authorization.user_code %></dd>
15
+ </dl>
16
+
17
+ <div class="actions">
18
+ <%= button_to("Approve",
19
+ admin_device_authorization_path(device_authorization.user_code),
20
+ method: :patch,
21
+ params: { decision: "approve" },
22
+ class: "button") %>
23
+ <%= button_to("Deny",
24
+ admin_device_authorization_path(device_authorization.user_code),
25
+ method: :patch,
26
+ params: { decision: "deny" },
27
+ class: "button button--secondary") %>
28
+ </div>
29
+ <% end %>
30
+
31
+ <%= summary_table_with(model: device_authorization) do |row| %>
32
+ <% row.enum(:status) %>
33
+ <% row.text(:requested_ip) %>
34
+ <% row.text(:user_agent) %>
35
+ <% row.datetime(:request_expires_at) %>
36
+ <% row.datetime(:token_expires_at) %>
37
+ <% end %>
38
+ <% end %>
@@ -2,7 +2,7 @@
2
2
 
3
3
  <%= form_with(id: dom_id(admin_user, :otp),
4
4
  model: admin_user,
5
- url: admin_admin_user_otp_path(admin_user),
5
+ url: admin_profile_otp_path,
6
6
  method: :post,
7
7
  class: "flow") do |form| %>
8
8
  <section class="flow prose">
@@ -1,6 +1,6 @@
1
1
  <%# locals: (admin_user:) %>
2
2
 
3
- <%= form_with(model: admin_user) do |form| %>
3
+ <%= form_with(model: admin_user, url: admin_profile_path) do |form| %>
4
4
  <%= form.govuk_text_field :email %>
5
5
  <%= form.govuk_text_field :name %>
6
6
  <%= form.govuk_password_field :password, label: { text: "Password (optional)" } %>