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 67ebdfe
Show file tree
Hide file tree
Showing 19 changed files with 367 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);
}
}
9 changes: 9 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,13 @@
align-items: stretch;
flex-direction: row;
column-gap: 0.5rem;

.action.copy-to-clipboard {
display: flex;
flex: auto;

input[type="text"] {
flex: auto;
}
}
}
37 changes: 37 additions & 0 deletions app/assets/stylesheets/koi/components/_clipboard.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.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;
}
}
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
5 changes: 5 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,7 @@
.admin-login .button--primary {
flex: 1;
}

#flash {
@include flash.flash;
}
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="action copy-to-clipboard govuk-input__wrapper" data-controller="clipboard" data-clipboard-supported-class="clipboard--supported">
<%= text_field_tag :invite_link, new_admin_session_url(token: url_encode(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(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
24 changes: 24 additions & 0 deletions spec/models/admin/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@
it { is_expected.to allow_values("[email protected]").for(:email) }
it { is_expected.not_to allow_values("@b.com", "fail").for(:email) }

describe "password validation" do
subject!(:admin) { build(:admin, password: "") }

it "does not validate password on create" do
admin.validate
expect(admin.errors).to be_empty
end

it "does validate password on update" do
admin.save!
admin.last_sign_in_at = Time.current
admin.validate
expect(admin.errors.messages_for(:password)).to include("can't be blank")
end

it "does not validate password on update when passkey is present" do
admin.credentials.new(external_id: "id", public_key: "key", sign_count: 1, nickname: "pc")
admin.save!
admin.last_sign_in_at = Time.current
admin.validate
expect(admin.errors).to be_empty
end
end

describe "#admin_search" do
it { expect(described_class.admin_search(admin.name)).to include(admin) }
it { expect(described_class.admin_search(admin.email)).to include(admin) }
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
Loading

0 comments on commit 67ebdfe

Please sign in to comment.