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.
- checksums.yaml +4 -4
- data/README.md +6 -12
- data/app/assets/builds/katalyst/koi.esm.js +9 -19
- data/app/assets/builds/katalyst/koi.js +9 -19
- data/app/assets/builds/katalyst/koi.min.js +1 -1
- data/app/assets/builds/katalyst/koi.min.js.map +1 -1
- data/app/assets/stylesheets/koi/blocks/index.css +1 -0
- data/app/assets/stylesheets/koi/{login.css → blocks/roadblock.css} +8 -5
- data/app/controllers/admin/admin_users_controller.rb +3 -2
- data/app/controllers/admin/credentials_controller.rb +33 -51
- data/app/controllers/admin/device_authorizations_controller.rb +58 -0
- data/app/controllers/admin/device_tokens_controller.rb +18 -0
- data/app/controllers/admin/otps_controller.rb +6 -16
- data/app/controllers/admin/profiles_controller.rb +33 -0
- data/app/controllers/admin/sessions_controller.rb +12 -6
- data/app/controllers/admin/tokens_controller.rb +6 -2
- data/app/controllers/concerns/koi/controller/has_admin_users.rb +8 -11
- data/app/controllers/concerns/koi/controller/has_webauthn.rb +54 -9
- data/app/controllers/concerns/koi/controller.rb +7 -0
- data/app/javascript/koi/controllers/webauthn_authentication_controller.js +1 -1
- data/app/javascript/koi/controllers/webauthn_registration_controller.js +8 -18
- data/app/jobs/admin/device_authorizations_cleanup_job.rb +9 -0
- data/app/models/admin/credential.rb +4 -0
- data/app/models/admin/device_authorization.rb +113 -0
- data/app/models/admin/user.rb +1 -0
- data/app/models/koi/current.rb +8 -0
- data/app/views/admin/admin_users/edit.html.erb +3 -1
- data/app/views/admin/admin_users/show.html.erb +3 -0
- data/app/views/admin/credentials/_credentials.html.erb +8 -5
- data/app/views/admin/credentials/new.html.erb +32 -41
- data/app/views/admin/credentials/show.html.erb +19 -0
- data/app/views/admin/device_authorizations/show.html.erb +38 -0
- data/app/views/admin/otps/_form.html.erb +1 -1
- data/app/views/admin/{admin_users/_form.html+self.erb → profiles/_form.html.erb} +1 -1
- data/app/views/admin/{admin_users/edit.html+self.erb → profiles/edit.html.erb} +0 -1
- data/app/views/admin/{admin_users/show.html+self.erb → profiles/show.html.erb} +15 -10
- data/app/views/admin/sessions/new.html.erb +26 -27
- data/app/views/admin/sessions/otp.html.erb +13 -5
- data/app/views/admin/sessions/password.html.erb +16 -8
- data/app/views/admin/tokens/show.html.erb +12 -8
- data/app/views/layouts/koi/_application_navigation.html.erb +13 -9
- data/app/views/layouts/koi/application.html.erb +19 -10
- data/config/locales/koi.en.yml +0 -1
- data/config/routes.rb +17 -9
- data/db/migrate/20260413014834_create_admin_device_authorizations.rb +20 -0
- data/lib/generators/koi/helpers/resource_helpers.rb +1 -1
- data/lib/koi/config.rb +2 -1
- data/lib/koi/engine.rb +1 -0
- data/lib/koi/middleware/admin_authentication.rb +54 -10
- data/spec/factories/admin_device_authorizations.rb +29 -0
- metadata +30 -10
- data/app/views/admin/credentials/_credentials.html+self.erb +0 -12
- data/app/views/admin/credentials/create.turbo_stream.erb +0 -5
- data/app/views/admin/credentials/destroy.turbo_stream.erb +0 -5
- 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
|
-
|
|
11
|
+
attr_reader :admin_user
|
|
12
12
|
|
|
13
13
|
def new
|
|
14
|
-
|
|
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!(
|
|
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
|
-
|
|
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] =
|
|
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] }
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
rescue ActiveRecord::RecordNotFound
|
|
19
|
-
false
|
|
17
|
+
Koi::Current.admin_user.present?
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def current_admin_user
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
20
|
+
module Helper
|
|
21
|
+
def webauthn_authentication_options_value
|
|
22
|
+
options = controller.webauthn_relying_party.options_for_authentication
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
session[:authentication_challenge] = options.challenge
|
|
24
25
|
|
|
25
|
-
|
|
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
|
|
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(
|
|
33
|
-
session
|
|
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
|
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
) {
|
|
15
|
-
e.
|
|
16
|
-
|
|
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
|
|
26
|
+
this.optionsValue,
|
|
37
27
|
),
|
|
38
28
|
};
|
|
39
29
|
}
|
|
@@ -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
|
data/app/models/admin/user.rb
CHANGED
|
@@ -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
|
|
@@ -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:
|
|
4
|
-
<%
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
1
|
+
<%# locals: (admin_user:) %>
|
|
2
2
|
|
|
3
|
-
<%=
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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"> </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 %>
|
|
@@ -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)" } %>
|