Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate invitations to enrol new admin users #582

Merged
merged 1 commit into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/assets/javascripts/koi/controllers/clipboard_controller.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
46 changes: 46 additions & 0 deletions app/assets/stylesheets/koi/components/_clipboard.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions app/assets/stylesheets/koi/components/_index.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@use "actions-group";
@use "clipboard";
@use "document-field";
@use "image-field";
@use "index-actions";
Expand Down
6 changes: 6 additions & 0 deletions app/assets/stylesheets/koi/pages/_login.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@use "../layouts/navigation" as nav;
@use "../layouts/flash";

.admin-login {
display: flex;
Expand Down Expand Up @@ -38,3 +39,8 @@
.admin-login .button--primary {
flex: 1;
}

.admin-login .govuk-error-summary {
padding: 15px;
margin-bottom: 30px;
}
60 changes: 60 additions & 0 deletions app/controllers/admin/tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions app/controllers/concerns/koi/controller/json_web_token.rb
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion app/models/admin/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 9 additions & 6 deletions app/views/admin/admin_users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>

<div class="actions">
Expand All @@ -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 %>
</div>

<h2>Authentication</h2>
Expand Down
9 changes: 9 additions & 0 deletions app/views/admin/sessions/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
webauthn_authentication_options_value: { publicKey: webauthn_auth_options },
},
) do |f| %>
<% unless flash.empty? %>
<div class="govuk-error-summary">
<ul class="govuk-error-summary__list">
<% flash.each do |_, message| %>
<%= tag.li message %>
<% end %>
</ul>
</div>
<% end %>
<%= f.govuk_fieldset legend: nil do %>
<%= f.govuk_email_field :email, autofocus: true, autocomplete: "email" %>
<%= f.govuk_password_field :password, autocomplete: "current-password" %>
Expand Down
9 changes: 9 additions & 0 deletions app/views/admin/tokens/create.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<%= turbo_stream.replace "invite" do %>
<div class="action copy-to-clipboard govuk-input__wrapper" data-controller="clipboard" data-clipboard-supported-class="clipboard--supported">
<%= text_field_tag :invite_link, admin_token_url(token), readonly: true, data: { clipboard_target: "source" } %>
<button class="govuk-input__suffix clipboard-button" aria-hidden="true" data-action="clipboard#copy">
Copy link
</button>
<div class="copy-to-clipboard-feedback" role="alert"></div>
</div>
<% end %>
13 changes: 13 additions & 0 deletions app/views/admin/tokens/show.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>
<p>Welcome to Koi Admin</p>
<%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %>
<%= builder.text :name %>
<%= builder.text :email %>
<% end %>
<div class="actions-group">
<%= form.admin_save "Sign up" %>
</div>
<% end %>
9 changes: 8 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: /[^\/]+/
hasarindaKI marked this conversation as resolved.
Show resolved Hide resolved

resource :cache, only: %i[destroy]
resource :dashboard, only: %i[show]

Expand Down
78 changes: 78 additions & 0 deletions spec/requests/admin/tokens_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading