diff --git a/app/assets/javascripts/koi/controllers/clipboard_controller.js b/app/assets/javascripts/koi/controllers/clipboard_controller.js new file mode 100644 index 000000000..ad01b5dc8 --- /dev/null +++ b/app/assets/javascripts/koi/controllers/clipboard_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class ClipboardController extends Controller { + static targets = ["source"]; + + static classes = ["supported"]; + + connect() { + if ("clipboard" in navigator) { + this.element.classList.add(this.supportedClass); + } + } + + copy(event) { + event.preventDefault(); + navigator.clipboard.writeText(this.sourceTarget.value); + + this.element.classList.add("copied"); + setTimeout(() => { + this.element.classList.remove("copied"); + }, 2000); + } +} diff --git a/app/assets/stylesheets/koi/components/_clipboard.scss b/app/assets/stylesheets/koi/components/_clipboard.scss new file mode 100644 index 000000000..4fc31ef9b --- /dev/null +++ b/app/assets/stylesheets/koi/components/_clipboard.scss @@ -0,0 +1,46 @@ +.copy-to-clipboard { + position: relative; + + .copy-to-clipboard-feedback { + animation: ease-in-out 0.5s; + display: none; + position: absolute; + top: 0; + right: 0; + left: 0; + margin-top: -1em; + z-index: 10; + white-space: nowrap; + text-align: center; + + &:after { + content: "Link copied to your clipboard!"; + display: inline-block; + padding: 0.33em 1em 0.33em 1em; + border-radius: 3rem; + color: white; + background-color: black; + } + } + + &.copied .copy-to-clipboard-feedback { + display: block; + } + + .clipboard-button { + display: none; + } + + &.clipboard--supported .clipboard-button { + display: initial; + } +} + +.actions .action.copy-to-clipboard { + display: flex; + flex: auto; + + input[type="text"] { + flex: auto; + } +} diff --git a/app/assets/stylesheets/koi/components/_index.scss b/app/assets/stylesheets/koi/components/_index.scss index a64642bc0..10820195e 100644 --- a/app/assets/stylesheets/koi/components/_index.scss +++ b/app/assets/stylesheets/koi/components/_index.scss @@ -1,4 +1,5 @@ @use "actions-group"; +@use "clipboard"; @use "document-field"; @use "image-field"; @use "index-actions"; diff --git a/app/assets/stylesheets/koi/pages/_login.scss b/app/assets/stylesheets/koi/pages/_login.scss index 5166461a8..7b653f5cd 100644 --- a/app/assets/stylesheets/koi/pages/_login.scss +++ b/app/assets/stylesheets/koi/pages/_login.scss @@ -1,4 +1,5 @@ @use "../layouts/navigation" as nav; +@use "../layouts/flash"; .admin-login { display: flex; @@ -38,3 +39,8 @@ .admin-login .button--primary { flex: 1; } + +.admin-login .govuk-error-summary { + padding: 15px; + margin-bottom: 30px; +} diff --git a/app/controllers/admin/tokens_controller.rb b/app/controllers/admin/tokens_controller.rb new file mode 100644 index 000000000..eb1241c34 --- /dev/null +++ b/app/controllers/admin/tokens_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Admin + class TokensController < ApplicationController + include Koi::Controller::JsonWebToken + + skip_before_action :authenticate_admin, only: %i[show update] + before_action :set_token, only: %i[show update] + + def create + admin = Admin::User.find(params[:id]) + token = encode_token(admin_id: admin.id, exp: 24.hours.from_now.to_i, iat: Time.now.to_i) + + render locals: { token: } + end + + def update + return redirect_to admin_dashboard_path, status: :see_other if admin_signed_in? + + return redirect_to new_admin_session_path, status: :see_other, notice: "invalid token" if @token.blank? + + admin = Admin::User.find(@token[:admin_id]) + sign_in_admin(admin) + + redirect_to admin_admin_user_path(admin) + end + + def show + return redirect_to new_admin_session_path, notice: "Token invalid or consumed already" if @token.blank? + + admin = Admin::User.find(@token[:admin_id]) + + if token_utilised?(admin, @token) + return redirect_to new_admin_session_path, notice: "Token invalid or consumed already" + end + + render locals: { admin:, token: params[:token] }, layout: "koi/login" + end + + private + + def set_token + @token = decode_token(params[:token]) + end + + def token_utilised?(admin, token) + admin.current_sign_in_at.present? || (admin.last_sign_in_at.present? && admin.last_sign_in_at.to_i > token[:iat]) + end + + def sign_in_admin(admin) + admin.current_sign_in_at = Time.current + admin.current_sign_in_ip = request.remote_ip + admin.sign_in_count = 1 + + # disable validations to allow saving without password or passkey credentials + admin.save!(validate: false) + session[:admin_user_id] = admin.id + end + end +end diff --git a/app/controllers/concerns/koi/controller/json_web_token.rb b/app/controllers/concerns/koi/controller/json_web_token.rb new file mode 100644 index 000000000..e6491d7fe --- /dev/null +++ b/app/controllers/concerns/koi/controller/json_web_token.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Koi + module Controller + module JsonWebToken + extend ActiveSupport::Concern + + SECRET_KEY = Rails.application.secret_key_base + + def encode_token(**payload) + JWT.encode(payload, SECRET_KEY) + end + + def decode_token(token) + payload = JWT.decode(token, SECRET_KEY)[0] + HashWithIndifferentAccess.new(payload) + rescue JWT::DecodeError + nil + end + end + end +end diff --git a/app/models/admin/user.rb b/app/models/admin/user.rb index b97d9c85f..77e2ebc96 100644 --- a/app/models/admin/user.rb +++ b/app/models/admin/user.rb @@ -8,7 +8,8 @@ def self.model_name ActiveModel::Name.new(self, nil, "Admin") end - has_secure_password :password + # disable validations for password_digest + has_secure_password validations: false has_many :credentials, inverse_of: :admin, class_name: "Admin::Credential", dependent: :destroy diff --git a/app/views/admin/admin_users/show.html.erb b/app/views/admin/admin_users/show.html.erb index 72420731b..0b57c3a15 100644 --- a/app/views/admin/admin_users/show.html.erb +++ b/app/views/admin/admin_users/show.html.erb @@ -2,12 +2,12 @@ <%= render Koi::Header::ShowComponent.new(resource: admin) %> <% end %> -<%= definition_list(class: "item-table") do |builder| %> - <%= builder.item admin, :name %> - <%= builder.item admin, :email %> - <%= builder.item admin, :created_at %> - <%= builder.item admin, :last_sign_in_at, label: { text: "Last sign in" } %> - <%= builder.item admin, :archived? %> +<%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %> + <%= builder.text :name %> + <%= builder.text :email %> + <%= builder.datetime :created_at %> + <%= builder.datetime :last_sign_in_at, label: { text: "Last sign in" } %> + <%= builder.boolean :archived? %> <% end %>
Welcome to Koi Admin
+ <%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %> + <%= builder.text :name %> + <%= builder.text :email %> + <% end %> +