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 %>
@@ -17,6 +17,9 @@ method: :delete, form: { data: { turbo_confirm: "Are you sure?" } } %> <% end %> + <% if admin != current_admin %> + <%= button_to "Invite", invite_admin_admin_user_path(admin), class: "button button--primary", form: { id: "invite" } %> + <% end %>

Authentication

diff --git a/app/views/admin/sessions/new.html.erb b/app/views/admin/sessions/new.html.erb index b165894b8..39aa5e0d8 100644 --- a/app/views/admin/sessions/new.html.erb +++ b/app/views/admin/sessions/new.html.erb @@ -7,6 +7,15 @@ webauthn_authentication_options_value: { publicKey: webauthn_auth_options }, }, ) do |f| %> + <% unless flash.empty? %> +
+ +
+ <% end %> <%= f.govuk_fieldset legend: nil do %> <%= f.govuk_email_field :email, autofocus: true, autocomplete: "email" %> <%= f.govuk_password_field :password, autocomplete: "current-password" %> diff --git a/app/views/admin/tokens/create.turbo_stream.erb b/app/views/admin/tokens/create.turbo_stream.erb new file mode 100644 index 000000000..d343ac70c --- /dev/null +++ b/app/views/admin/tokens/create.turbo_stream.erb @@ -0,0 +1,9 @@ +<%= turbo_stream.replace "invite" do %> +
+ <%= text_field_tag :invite_link, admin_token_url(token), readonly: true, data: { clipboard_target: "source" } %> + + +
+<% end %> diff --git a/app/views/admin/tokens/show.html.erb b/app/views/admin/tokens/show.html.erb new file mode 100644 index 000000000..86dca1fd8 --- /dev/null +++ b/app/views/admin/tokens/show.html.erb @@ -0,0 +1,13 @@ +<%= render "layouts/koi/navigation_header" %> + +<%= form_with(url: accept_admin_session_path) do |form| %> + <%= tag.input name: :token, type: :hidden, value: token %> +

Welcome to Koi Admin

+ <%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %> + <%= builder.text :name %> + <%= builder.text :email %> + <% end %> +
+ <%= form.admin_save "Sign up" %> +
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index 7e24b5bec..a8df9c4ae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,13 +2,20 @@ Rails.application.routes.draw do namespace :admin do - resource :session, only: %i[new create destroy] + resource :session, only: %i[new create destroy] do + post :accept, to: "tokens#update" + end resources :url_rewrites resources :admin_users do resources :credentials, only: %i[new create destroy] + post :invite, on: :member, to: "tokens#create" end + # JWT tokens have dots(represents the 3 parts of data) in them, so we need to allow them in the URL + # can by pass if we use token as a query param + get "token/:token", to: "tokens#show", as: :token, token: /[^\/]+/ + resource :cache, only: %i[destroy] resource :dashboard, only: %i[show] diff --git a/spec/requests/admin/tokens_controller_spec.rb b/spec/requests/admin/tokens_controller_spec.rb new file mode 100644 index 000000000..c7e2fd96e --- /dev/null +++ b/spec/requests/admin/tokens_controller_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::TokensController do + subject { action && response } + + def jwt_token(**payload) + JWT.encode(payload, Rails.application.secret_key_base) + end + + describe "GET /admin/token/:token" do + let(:action) { get admin_token_path(token) } + let(:admin) { create(:admin, password: "") } + let(:token) { jwt_token(admin_id: admin.id, exp: 5.seconds.from_now.to_i, iat: Time.now.to_i) } + + it { is_expected.to be_successful } + + context "with used token" do + let(:admin) { create(:admin, last_sign_in_at: Time.now) } + let(:token) { jwt_token(admin_id: admin.id, exp: 5.seconds.from_now.to_i, iat: 1.hour.ago.to_i) } + + it { is_expected.to redirect_to(new_admin_session_path) } + + it "shows a flash message" do + action + expect(flash[:notice]).to match(/Token invalid or consumed already/) + end + end + + context "with invalid token" do + let(:token) { "token" } + + it { is_expected.to redirect_to(new_admin_session_path) } + + it "shows a flash message" do + action + expect(flash[:notice]).to match(/Token invalid or consumed already/) + end + end + end + + describe "POST /admin/admin_users/:id/invite" do + let(:action) { post invite_admin_admin_user_path(admin), as: :turbo_stream } + let(:admin) { create(:admin) } + + include_context "with admin session" + + it_behaves_like "requires admin" + + it { is_expected.to be_successful } + + it "renders the token" do + action + expect(response.body).to have_css("turbo-stream[action=replace][target='invite']") + end + end + + describe "POST /admin/session/accept" do + let(:action) { post accept_admin_session_path, params: { token: } } + let(:admin) { create(:admin, password: "") } + let(:token) { jwt_token(admin_id: admin.id, exp: 5.seconds.from_now.to_i, iat: Time.now.to_i) } + + it { is_expected.to redirect_to(admin_admin_user_path(admin)) } + + it "updates the admin login details" do + expect { action }.to change { admin.reload.current_sign_in_at }.from(nil).to be_present + end + + context "when admin is signed in" do + let(:admin) { create(:admin) } + + include_context "with admin session" + + it { is_expected.to redirect_to(admin_dashboard_path) } + end + end +end diff --git a/spec/system/admin/invitation_spec.rb b/spec/system/admin/invitation_spec.rb new file mode 100644 index 000000000..f1d408c9e --- /dev/null +++ b/spec/system/admin/invitation_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "admin/invites" do + def encode_token(**args) + JWT.encode(args, Rails.application.secret_key_base) + end + + it "creates an invitation" do + admin = create(:admin) + visit "/admin" + + fill_in "Email", with: admin.email + fill_in "Password", with: admin.password + click_on "Log in" + + visit "/admin/admin_users/new" + + fill_in "Email", with: "john.doe@gmail.com" + fill_in "Name", with: "John Doe" + + click_button "Save" + + expect(page).to have_css("button", text: "Invite") + + click_button "Invite" + + expect(page).to have_css("input[type=text][value*=token]") + end + + it "can accept an invitation" do + admin = create(:admin, password: "") + token = encode_token(admin_id: admin.id, exp: 1.hour.from_now.to_i, ist: Time.now.to_i) + + visit "admin/token/#{token}" + + expect(page).to have_content(/Welcome to Koi Admin/) + + click_button "Sign up" + + expect(page).to have_current_path(admin_admin_user_path(admin)) + end +end