Skip to content

Commit

Permalink
Generate invitations to enrol new admin users
Browse files Browse the repository at this point in the history
* Adds new endpoint to handle invitations
* Enables generating invitation link to share from admin user show page
* Adds a signup page using token details
* updates validations on password, so that password is optional
  • Loading branch information
hasarindaKI committed Mar 7, 2024
1 parent 2f05ae1 commit 65dd55d
Show file tree
Hide file tree
Showing 14 changed files with 328 additions and 8 deletions.
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: 5.minutes.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
13 changes: 7 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,7 @@
method: :delete,
form: { data: { turbo_confirm: "Are you sure?" } } %>
<% end %>
<%= button_to "Invite", invite_admin_admin_user_path(admin), class: "button button--primary", form: { id: "invite" } %>
</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 in" %>
</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: /[^\/]+/

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
44 changes: 44 additions & 0 deletions spec/system/admin/invitation_spec.rb
Original file line number Diff line number Diff line change
@@ -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: "[email protected]"
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 in"

expect(page).to have_current_path(admin_admin_user_path(admin))
end
end

0 comments on commit 65dd55d

Please sign in to comment.