diff --git a/app/assets/javascripts/koi/controllers/webauthn_registration_controller.js b/app/assets/javascripts/koi/controllers/webauthn_registration_controller.js index b0ff5b92..25883639 100644 --- a/app/assets/javascripts/koi/controllers/webauthn_registration_controller.js +++ b/app/assets/javascripts/koi/controllers/webauthn_registration_controller.js @@ -6,8 +6,11 @@ import { } from "@github/webauthn-json/browser-ponyfill"; export default class WebauthnRegistrationController extends Controller { - static values = { options: Object }; - static targets = ["response"]; + static values = { + options: Object, + response: String, + }; + static targets = ["intro", "nickname", "response"]; submit(e) { if (this.responseTarget.value === "") { @@ -16,12 +19,17 @@ export default class WebauthnRegistrationController extends Controller { } } - createCredential() { - create(this.options).then((response) => { - this.responseTarget.value = JSON.stringify(response); + async createCredential() { + const response = await create(this.options); - this.element.requestSubmit(); - }); + this.responseValue = JSON.stringify(response); + this.responseTarget.value = JSON.stringify(response); + } + + responseValueChanged(response) { + const responsePresent = response !== ""; + this.introTarget.toggleAttribute("hidden", responsePresent); + this.nicknameTarget.toggleAttribute("hidden", !responsePresent); } get options() { diff --git a/app/assets/stylesheets/koi/base/_flow.scss b/app/assets/stylesheets/koi/base/_flow.scss new file mode 100644 index 00000000..7630019b --- /dev/null +++ b/app/assets/stylesheets/koi/base/_flow.scss @@ -0,0 +1,8 @@ +/* +FLOW COMPOSITION +Like the Every Layout stack: https://every-layout.dev/layouts/stack/ +Info about this implementation: https://piccalil.li/quick-tip/flow-utility/ +*/ +.flow > * + * { + margin-top: var(--flow-space, 1em); +} diff --git a/app/assets/stylesheets/koi/base/_index.scss b/app/assets/stylesheets/koi/base/_index.scss index eae4b74d..ef550c0f 100644 --- a/app/assets/stylesheets/koi/base/_index.scss +++ b/app/assets/stylesheets/koi/base/_index.scss @@ -1,8 +1,10 @@ @use "button"; @use "icon"; @use "input"; +@use "flow"; @use "link"; @use "list"; +@use "repel"; @use "tables"; @use "typography"; diff --git a/app/assets/stylesheets/koi/base/_repel.scss b/app/assets/stylesheets/koi/base/_repel.scss new file mode 100644 index 00000000..78179a7e --- /dev/null +++ b/app/assets/stylesheets/koi/base/_repel.scss @@ -0,0 +1,23 @@ +/* +REPEL +A little layout that pushes items away from each other where +there is space in the viewport and stacks on small viewports + +CUSTOM PROPERTIES AND CONFIGURATION +--gutter (var(--space-s-m)): This defines the space +between each item. + +--repel-vertical-alignment How items should align vertically. +Can be any acceptable flexbox alignment value. +*/ +.repel { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: var(--repel-vertical-alignment, center); + gap: var(--gutter, var(--space-s-m)); +} + +.repel[data-nowrap] { + flex-wrap: nowrap; +} diff --git a/app/controllers/concerns/koi/controller/has_webauthn.rb b/app/controllers/concerns/koi/controller/has_webauthn.rb index c18326a6..2b7346b3 100644 --- a/app/controllers/concerns/koi/controller/has_webauthn.rb +++ b/app/controllers/concerns/koi/controller/has_webauthn.rb @@ -36,7 +36,8 @@ def webauthn_authenticate! Admin::Credential.find_by!(external_id: credential.id) end - stored_credential.update!(sign_count: webauthn_credential.sign_count) + stored_credential.update(sign_count: webauthn_credential.sign_count) + stored_credential.touch stored_credential.admin end diff --git a/app/views/admin/admin_users/index.html.erb b/app/views/admin/admin_users/index.html.erb index 202e317c..8bedd81a 100644 --- a/app/views/admin/admin_users/index.html.erb +++ b/app/views/admin/admin_users/index.html.erb @@ -15,6 +15,9 @@ <% row.select %> <% row.link :name, url: :admin_admin_user_path %> <% row.text :email %> + <% row.boolean :credentials, label: "Passkey" do |cell| %> + <%= cell.value.any? ? "Yes" : "No" %> + <% end %> <% end %> <%= table_pagination_with(collection:) %> diff --git a/app/views/admin/admin_users/show.html+self.erb b/app/views/admin/admin_users/show.html+self.erb index 3a8b88b6..d68197b7 100644 --- a/app/views/admin/admin_users/show.html+self.erb +++ b/app/views/admin/admin_users/show.html+self.erb @@ -11,10 +11,9 @@ <%= builder.date :last_sign_in_at, label: { text: "Last sign in" } %> <% end %> -

Passkeys

+
+

Passkeys

+ <%= kpop_link_to "New passkey", new_admin_admin_user_credential_path(admin), class: "button button--primary" %> +
<%= render "admin/credentials/credentials", admin: %> - -
- <%= kpop_link_to "Add this device", new_admin_admin_user_credential_path(admin), class: "button button--primary" %> -
diff --git a/app/views/admin/credentials/_credentials.html+self.erb b/app/views/admin/credentials/_credentials.html+self.erb new file mode 100644 index 00000000..3165c492 --- /dev/null +++ b/app/views/admin/credentials/_credentials.html+self.erb @@ -0,0 +1,10 @@ +<%= table_with(id: dom_id(admin, :credentials), collection: admin.credentials) do |t, c| %> + <% t.text :nickname, label: "Name" %> + <% t.date :updated_at, label: "Last use" do |date| %> + <%= date unless c.created_at == c.updated_at %> + <% end %> + <% t.cell :actions, label: "" do %> + <%= link_to("Remove passkey", admin_admin_user_credential_path(admin, c), + data: { turbo_method: :delete }) %> + <% end %> +<% end %> diff --git a/app/views/admin/credentials/_credentials.html.erb b/app/views/admin/credentials/_credentials.html.erb index 4780404f..50c6cd00 100644 --- a/app/views/admin/credentials/_credentials.html.erb +++ b/app/views/admin/credentials/_credentials.html.erb @@ -1,7 +1,6 @@ <%= table_with(id: dom_id(admin, :credentials), collection: admin.credentials) do |t, c| %> - <% t.text :nickname %> - <% t.number :sign_count %> - <% t.cell :actions, label: "" do %> - <%= button_to "Remove device", admin_admin_user_credential_path(admin, c), method: :delete, class: "button button--text" %> - <% end if admin == current_admin %> + <% t.text :nickname, label: "Name" %> + <% t.date :updated_at, label: "Last use" do |date| %> + <%= date unless c.created_at == c.updated_at %> + <% end %> <% end %> diff --git a/app/views/admin/credentials/new.html.erb b/app/views/admin/credentials/new.html.erb index 02b2bc93..0946b223 100644 --- a/app/views/admin/credentials/new.html.erb +++ b/app/views/admin/credentials/new.html.erb @@ -1,14 +1,37 @@ -<%= render Kpop::ModalComponent.new(title: "Register device") do %> +<%= render Kpop::ModalComponent.new(title: "New passkey") do %> <%= form_with model: admin.credentials.new, url: admin_admin_user_credentials_path(admin), + class: "flow prose", data: { controller: "webauthn-registration", action: "submit->webauthn-registration#submit", webauthn_registration_options_value: { publicKey: options }, } do |form| %> - <%= form.govuk_text_field :nickname %> <%= form.hidden_field :response, data: { webauthn_registration_target: "response" } %> - - <%= form.admin_save %> +
+

+ Passkeys are secure secrets that are stored by your device. + You will need the device where your passkey is stored to log in. +

+

+ Unlike a password, your password doesn't get sent to the server when you log + in and can't be stolen in a data breach. When you log in with a passkey, + your operating system will prompt you for permission to use the passkey + secret to authenticate the login attempt. +

+

+ We recommend that you store your passkey on your phone or cloud account. + Depending on your browser, you may need to choose "more options" to see + a QR code that you can scan with your phone. +

+
+ + <%= form.admin_save("Next") %> <% end %> <% end %>