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 6, 2024
1 parent 2f05ae1 commit fc2f263
Show file tree
Hide file tree
Showing 15 changed files with 319 additions and 8 deletions.
15 changes: 15 additions & 0 deletions app/assets/javascripts/koi/controllers/clipboard_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Controller } from "@hotwired/stimulus";

export default class ClipboardController extends Controller {
static targets = [ "source" ]

copy(event) {
event.preventDefault()
navigator.clipboard.writeText(this.sourceTarget.value);

this.element.classList.add("copied");
setTimeout(() => {
this.element.classList.remove("copied");
}, 2000)
}
}
36 changes: 36 additions & 0 deletions app/assets/stylesheets/koi/components/_actions-group.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,40 @@
align-items: stretch;
flex-direction: row;
column-gap: 0.5rem;

.copy-to-clipboard-wrapper {
display: flex;
flex: auto;
position: relative;

input[type="text"] {
flex: auto;
}

.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 2.5em;
border-radius: 3rem;
color: white;
background-color: black;
}
}

&.copied .copy-to-clipboard--feedback {
display: block;
}
}
}
3 changes: 2 additions & 1 deletion app/controllers/admin/credentials_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ class CredentialsController < ApplicationController

def new
unless @admin_user.webauthn_id
@admin_user.update!(webauthn_id: WebAuthn.generate_user_id)
# disable validations to allow saving without password or passkey credentials
@admin_user.update_attribute!(:webauthn_id, WebAuthn.generate_user_id)
end

options = webauthn_relying_party.options_for_registration(
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/admin/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class SessionsController < ApplicationController
def new
return redirect_to admin_dashboard_path if admin_signed_in?

return redirect_to admin_token_path(params[:token]) if params[:token].present?

render :new, locals: { admin_user: Admin::User.new }
end

Expand Down
58 changes: 58 additions & 0 deletions app/controllers/admin/tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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: "invalid token" if @token.blank?

admin = Admin::User.find(@token[:admin_id])

return redirect_to new_admin_session_path, notice: "token already used" if token_utilised?(admin, @token)

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
13 changes: 12 additions & 1 deletion app/models/admin/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ def self.model_name
ActiveModel::Name.new(self, nil, "Admin")
end

has_secure_password :password
# disable validations for password_digest, as we don't want to validate the password on create
has_secure_password validations: false
# validate password on update if no credentials are present or password is present
validate :validate_password, on: :update, if: -> { credentials.blank? || password.present? }

has_many :credentials, inverse_of: :admin, class_name: "Admin::Credential", dependent: :destroy

Expand All @@ -26,5 +29,13 @@ def self.model_name
where("email LIKE :query OR name LIKE :query", query: "%#{query}%")
end
end

def validate_password
errors.add(:password, :blank) if password_digest.blank?

if password.present? && password.bytesize > ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
record.errors.add(:password, :password_too_long)
end
end
end
end
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 && admin.sign_in_count.zero? %>
<%= 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/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="copy-to-clipboard-wrapper govuk-input__wrapper" data-controller="clipboard">
<%= text_field_tag :invite_link, new_admin_session_url(token: url_encode(token)), readonly: true, data: { clipboard_target: "source" } %>
<button class="govuk-input__suffix" aria-hidden="true" data-action="clipboard#copy">
Copy link
</button>
<div class="copy-to-clipboard--feedback" role="alert" data-clipboard-target="feedback"></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(model: admin, url: accept_admin_admin_user_path(admin), method: :post) do |form| %>
<%= tag.input name: :token, type: :hidden, value: token %>
<p>Welcome to Admin Terminal</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 %>
3 changes: 3 additions & 0 deletions app/views/layouts/koi/login.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
</head>
<body class="admin-login">
<main class="stack">
<div>
<%= render "layouts/koi/flash" unless flash.empty? %>
</div>
<%= yield %>
</main>
</body>
Expand Down
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@
resources :url_rewrites
resources :admin_users do
resources :credentials, only: %i[new create destroy]
post :invite, on: :member, to: "tokens#create"
post :accept, on: :member, to: "tokens#update"
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
10 changes: 10 additions & 0 deletions spec/requests/admin/sessions_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
action
expect(response).to have_http_status(:success)
end

context "with token" do
let(:token) { "token" }
let(:action) { get new_admin_session_path, params: { token: } }

it "redirects to the token path" do
action
expect(response).to redirect_to(admin_token_path(token))
end
end
end

describe "POST /admin/sessions" do
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 already used/)
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(/invalid token/)
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/admin_users/:id/accept" do
let(:action) { post accept_admin_admin_user_path(admin), 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

0 comments on commit fc2f263

Please sign in to comment.