katalyst-koi 4.5.0 → 4.5.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,23 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class ClipboardController extends Controller {
4
+ static targets = ["source"];
5
+
6
+ static classes = ["supported"];
7
+
8
+ connect() {
9
+ if ("clipboard" in navigator) {
10
+ this.element.classList.add(this.supportedClass);
11
+ }
12
+ }
13
+
14
+ copy(event) {
15
+ event.preventDefault();
16
+ navigator.clipboard.writeText(this.sourceTarget.value);
17
+
18
+ this.element.classList.add("copied");
19
+ setTimeout(() => {
20
+ this.element.classList.remove("copied");
21
+ }, 2000);
22
+ }
23
+ }
@@ -0,0 +1,46 @@
1
+ .copy-to-clipboard {
2
+ position: relative;
3
+
4
+ .copy-to-clipboard-feedback {
5
+ animation: ease-in-out 0.5s;
6
+ display: none;
7
+ position: absolute;
8
+ top: 0;
9
+ right: 0;
10
+ left: 0;
11
+ margin-top: -1em;
12
+ z-index: 10;
13
+ white-space: nowrap;
14
+ text-align: center;
15
+
16
+ &:after {
17
+ content: "Link copied to your clipboard!";
18
+ display: inline-block;
19
+ padding: 0.33em 1em 0.33em 1em;
20
+ border-radius: 3rem;
21
+ color: white;
22
+ background-color: black;
23
+ }
24
+ }
25
+
26
+ &.copied .copy-to-clipboard-feedback {
27
+ display: block;
28
+ }
29
+
30
+ .clipboard-button {
31
+ display: none;
32
+ }
33
+
34
+ &.clipboard--supported .clipboard-button {
35
+ display: initial;
36
+ }
37
+ }
38
+
39
+ .actions .action.copy-to-clipboard {
40
+ display: flex;
41
+ flex: auto;
42
+
43
+ input[type="text"] {
44
+ flex: auto;
45
+ }
46
+ }
@@ -1,4 +1,5 @@
1
1
  @use "actions-group";
2
+ @use "clipboard";
2
3
  @use "document-field";
3
4
  @use "image-field";
4
5
  @use "index-actions";
@@ -4,7 +4,6 @@ $separator: rgba(255, 255, 255, 0.2);
4
4
  %subheading {
5
5
  color: $heading;
6
6
  font-size: 0.8rem;
7
- text-transform: uppercase;
8
7
  font-weight: 400;
9
8
  margin: 0;
10
9
  }
@@ -1,4 +1,5 @@
1
1
  @use "../layouts/navigation" as nav;
2
+ @use "../layouts/flash";
2
3
 
3
4
  .admin-login {
4
5
  display: flex;
@@ -38,3 +39,8 @@
38
39
  .admin-login .button--primary {
39
40
  flex: 1;
40
41
  }
42
+
43
+ .admin-login .govuk-error-summary {
44
+ padding: 15px;
45
+ margin-bottom: 30px;
46
+ }
@@ -3,3 +3,41 @@
3
3
  [data-controller="content--editor--container"] {
4
4
  --heading--h4: 1rem;
5
5
  }
6
+
7
+ [data-controller="content--editor--table"] {
8
+ .content--editor--table-editor {
9
+ // ensure the table editor is easy to target when empty
10
+ &:not(:has(table)) {
11
+ min-height: 4rem !important;
12
+ }
13
+
14
+ // tight wrap the table editor around the table and use table borders
15
+ &:has(table) {
16
+ border: none !important;
17
+ padding: 0 !important;
18
+ }
19
+
20
+ table {
21
+ width: 100%;
22
+ }
23
+
24
+ table,
25
+ th,
26
+ td {
27
+ border: 2px solid black;
28
+ }
29
+ }
30
+
31
+ // restore webkit spinners, hidden by govuk
32
+ // these are not ideal, but spinners are much easier to work with for this
33
+ // use case than the default number input, because we submit the form on
34
+ // change to implement the live preview
35
+ input[type="number"]::-webkit-inner-spin-button {
36
+ position: relative;
37
+ right: -1px;
38
+ margin-top: -2px;
39
+ margin-bottom: -4px;
40
+ -webkit-appearance: inner-spin-button !important;
41
+ opacity: 1 !important;
42
+ }
43
+ }
@@ -1,4 +1,4 @@
1
- <%= form_with model:, scope: :item, url:, builder: do |form| %>
1
+ <%= form_with model:, scope: :item, url:, builder:, **html_attributes do |form| %>
2
2
  <%= form.hidden_field :container_type %>
3
3
  <%= form.hidden_field :container_id %>
4
4
  <%= form.hidden_field :type %>
@@ -4,10 +4,12 @@ module Koi
4
4
  module Content
5
5
  module Editor
6
6
  class ItemFormComponent < ViewComponent::Base
7
+ include Katalyst::HtmlAttributes
8
+
7
9
  attr_reader :model, :url, :builder, :form
8
10
 
9
- def initialize(model:, url:, builder: Koi::FormBuilder)
10
- super()
11
+ def initialize(model:, url:, builder: Koi::FormBuilder, **)
12
+ super(**)
11
13
 
12
14
  @model = model
13
15
  @url = url
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class TokensController < ApplicationController
5
+ include Koi::Controller::JsonWebToken
6
+
7
+ skip_before_action :authenticate_admin, only: %i[show update]
8
+ before_action :set_token, only: %i[show update]
9
+
10
+ def create
11
+ admin = Admin::User.find(params[:id])
12
+ token = encode_token(admin_id: admin.id, exp: 5.minutes.from_now.to_i, iat: Time.current.to_i)
13
+
14
+ render locals: { token: }
15
+ end
16
+
17
+ def update
18
+ return redirect_to admin_dashboard_path, status: :see_other if admin_signed_in?
19
+
20
+ return redirect_to new_admin_session_path, status: :see_other, notice: "invalid token" if @token.blank?
21
+
22
+ admin = Admin::User.find(@token[:admin_id])
23
+ sign_in_admin(admin)
24
+
25
+ redirect_to admin_admin_user_path(admin)
26
+ end
27
+
28
+ def show
29
+ return redirect_to new_admin_session_path, notice: "Token invalid or consumed already" if @token.blank?
30
+
31
+ admin = Admin::User.find(@token[:admin_id])
32
+
33
+ if token_utilised?(admin, @token)
34
+ return redirect_to new_admin_session_path, notice: "Token invalid or consumed already"
35
+ end
36
+
37
+ render locals: { admin:, token: params[:token] }, layout: "koi/login"
38
+ end
39
+
40
+ private
41
+
42
+ def set_token
43
+ @token = decode_token(params[:token])
44
+ end
45
+
46
+ def token_utilised?(admin, token)
47
+ admin.current_sign_in_at.present? || (admin.last_sign_in_at.present? && admin.last_sign_in_at.to_i > token[:iat])
48
+ end
49
+
50
+ def sign_in_admin(admin)
51
+ admin.current_sign_in_at = Time.current
52
+ admin.current_sign_in_ip = request.remote_ip
53
+ admin.sign_in_count = 1
54
+
55
+ # disable validations to allow saving without password or passkey credentials
56
+ admin.save!(validate: false)
57
+ session[:admin_user_id] = admin.id
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ module Controller
5
+ module JsonWebToken
6
+ extend ActiveSupport::Concern
7
+
8
+ SECRET_KEY = Rails.application.secret_key_base
9
+
10
+ def encode_token(**payload)
11
+ JWT.encode(payload, SECRET_KEY)
12
+ end
13
+
14
+ def decode_token(token)
15
+ payload = JWT.decode(token, SECRET_KEY)[0]
16
+ HashWithIndifferentAccess.new(payload)
17
+ rescue JWT::DecodeError
18
+ nil
19
+ end
20
+ end
21
+ end
22
+ end
@@ -8,7 +8,8 @@ module Admin
8
8
  ActiveModel::Name.new(self, nil, "Admin")
9
9
  end
10
10
 
11
- has_secure_password :password
11
+ # disable validations for password_digest
12
+ has_secure_password validations: false
12
13
 
13
14
  has_many :credentials, inverse_of: :admin, class_name: "Admin::Credential", dependent: :destroy
14
15
 
@@ -2,12 +2,12 @@
2
2
  <%= render Koi::Header::ShowComponent.new(resource: admin) %>
3
3
  <% end %>
4
4
 
5
- <%= definition_list(class: "item-table") do |builder| %>
6
- <%= builder.item admin, :name %>
7
- <%= builder.item admin, :email %>
8
- <%= builder.item admin, :created_at %>
9
- <%= builder.item admin, :last_sign_in_at, label: { text: "Last sign in" } %>
10
- <%= builder.item admin, :archived? %>
5
+ <%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %>
6
+ <%= builder.text :name %>
7
+ <%= builder.text :email %>
8
+ <%= builder.datetime :created_at %>
9
+ <%= builder.datetime :last_sign_in_at, label: { text: "Last sign in" } %>
10
+ <%= builder.boolean :archived? %>
11
11
  <% end %>
12
12
 
13
13
  <div class="actions">
@@ -17,6 +17,7 @@
17
17
  method: :delete,
18
18
  form: { data: { turbo_confirm: "Are you sure?" } } %>
19
19
  <% end %>
20
+ <%= button_to "Generate login link", invite_admin_admin_user_path(admin), class: "button button--primary", form: { id: "invite" } %>
20
21
  </div>
21
22
 
22
23
  <h2>Authentication</h2>
@@ -7,6 +7,15 @@
7
7
  webauthn_authentication_options_value: { publicKey: webauthn_auth_options },
8
8
  },
9
9
  ) do |f| %>
10
+ <% unless flash.empty? %>
11
+ <div class="govuk-error-summary">
12
+ <ul class="govuk-error-summary__list">
13
+ <% flash.each do |_, message| %>
14
+ <%= tag.li message %>
15
+ <% end %>
16
+ </ul>
17
+ </div>
18
+ <% end %>
10
19
  <%= f.govuk_fieldset legend: nil do %>
11
20
  <%= f.govuk_email_field :email, autofocus: true, autocomplete: "email" %>
12
21
  <%= f.govuk_password_field :password, autocomplete: "current-password" %>
@@ -0,0 +1,9 @@
1
+ <%= turbo_stream.replace "invite" do %>
2
+ <div class="action copy-to-clipboard govuk-input__wrapper" data-controller="clipboard" data-clipboard-supported-class="clipboard--supported">
3
+ <%= text_field_tag :invite_link, admin_token_url(token), readonly: true, data: { clipboard_target: "source" } %>
4
+ <button class="govuk-input__suffix clipboard-button" aria-hidden="true" data-action="clipboard#copy">
5
+ Copy link
6
+ </button>
7
+ <div class="copy-to-clipboard-feedback" role="alert"></div>
8
+ </div>
9
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <%= render "layouts/koi/navigation_header" %>
2
+
3
+ <%= form_with(url: accept_admin_session_path) do |form| %>
4
+ <%= tag.input name: :token, type: :hidden, value: token %>
5
+ <p>Welcome to Koi Admin</p>
6
+ <%= render Koi::SummaryListComponent.new(model: admin, class: "item-table") do |builder| %>
7
+ <%= builder.text :name %>
8
+ <%= builder.text :email %>
9
+ <% end %>
10
+ <div class="actions-group">
11
+ <%= form.admin_save "Sign in" %>
12
+ </div>
13
+ <% end %>
@@ -0,0 +1,41 @@
1
+ <%= render Koi::Content::Editor::ItemFormComponent.new(model: table, url: path, data: { controller: "content--editor--table" }) do |form| %>
2
+ <%= form.content_heading_fieldset %>
3
+
4
+ <div class="govuk-form-group">
5
+ <label for="item-content-field" class="govuk-label govuk-label--s">Content</label>
6
+ <% content = sanitize_content_table(normalize_content_table(form.object, heading: false)) %>
7
+ <div class="govuk-textarea content--editor--table-editor"
8
+ contenteditable="true"
9
+ data-content--editor--table-target="content"
10
+ data-action="paste->content--editor--table#paste"
11
+ id="item-content-field">
12
+ <%= content %>
13
+ </div>
14
+ <%= form.hidden_field :content, value: content, data: { content__editor__table_target: "input" } %>
15
+ </div>
16
+
17
+ <%# hidden button to receive <Enter> events (HTML-default is to click first button in form) %>
18
+ <%= form.button "Save", hidden: "" %>
19
+
20
+ <%# hidden button to submit the table for re-rendering %>
21
+ <%= form.button "Update",
22
+ formaction: table.persisted? ? content_routes.table_path : content_routes.tables_path,
23
+ hidden: "",
24
+ data: { content__editor__table_target: "update" } %>
25
+
26
+ <%= form.govuk_number_field :heading_rows,
27
+ label: { text: "Heading rows" },
28
+ width: 2,
29
+ placeholder: 0,
30
+ min: 0,
31
+ data: { content__editor__table_target: "headerRows",
32
+ action: "input->content--editor--table#update" } %>
33
+
34
+ <%= form.govuk_number_field :heading_columns,
35
+ label: { text: "Heading columns" },
36
+ width: 2,
37
+ placeholder: 0,
38
+ min: 0,
39
+ data: { content__editor__table_target: "headerColumns",
40
+ action: "input->content--editor--table#update" } %>
41
+ <% end %>
@@ -2,7 +2,7 @@
2
2
  data-action="shortcut:go@document->navigation#focus
3
3
  navigation:toggle@document->navigation#toggle
4
4
  shortcut:nav-toggle@document->navigation#toggle">
5
- <%= render "layouts/koi/navigation_header" %>
5
+ <%= render "layouts/koi/navigation_header", admin: current_admin %>
6
6
  <div class="filter">
7
7
  <input type="search" placeholder="Filter menu" autocomplete="off"
8
8
  data-navigation-target="filter"
@@ -1,6 +1,11 @@
1
1
  <header>
2
2
  <h2 class="site-name"><%= URI.parse(root_url).host %></h2>
3
- <h3 class="admin-name">Koi Admin</h3>
3
+ <%# show username on nav header and Koi Admin on login page %>
4
+ <% if local_assigns[:admin].present? %>
5
+ <%= link_to admin.name, main_app.admin_admin_user_path(admin), class: "admin-name" %>
6
+ <% else %>
7
+ <h3 class="admin-name">Koi Admin</h3>
8
+ <% end %>
4
9
  <%# default, prefer using an icon in a Koi override %>
5
10
  <span class="site-icon"><%= URI.parse(root_url).host[0] %></span>
6
11
  </header>
data/config/routes.rb CHANGED
@@ -2,13 +2,20 @@
2
2
 
3
3
  Rails.application.routes.draw do
4
4
  namespace :admin do
5
- resource :session, only: %i[new create destroy]
5
+ resource :session, only: %i[new create destroy] do
6
+ post :accept, to: "tokens#update"
7
+ end
6
8
 
7
9
  resources :url_rewrites
8
10
  resources :admin_users do
9
11
  resources :credentials, only: %i[new create destroy]
12
+ post :invite, on: :member, to: "tokens#create"
10
13
  end
11
14
 
15
+ # JWT tokens have dots(represents the 3 parts of data) in them, so we need to allow them in the URL
16
+ # can by pass if we use token as a query param
17
+ get "token/:token", to: "tokens#show", as: :token, token: /[^\/]+/
18
+
12
19
  resource :cache, only: %i[destroy]
13
20
  resource :dashboard, only: %i[show]
14
21
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: katalyst-koi
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.5.0
4
+ version: 4.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-03-01 00:00:00.000000000 Z
11
+ date: 2024-03-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -204,6 +204,7 @@ files:
204
204
  - Upgrade.md
205
205
  - app/assets/builds/koi/admin.css
206
206
  - app/assets/builds/koi/admin.css.map
207
+ - app/assets/builds/koi/assets.css
207
208
  - app/assets/builds/koi/nav_items.css
208
209
  - app/assets/config/koi.js
209
210
  - app/assets/images/koi/application/chevron-right.svg
@@ -236,6 +237,7 @@ files:
236
237
  - app/assets/images/koi/application/sort-descending.png
237
238
  - app/assets/javascripts/koi/admin.js
238
239
  - app/assets/javascripts/koi/controllers/application.js
240
+ - app/assets/javascripts/koi/controllers/clipboard_controller.js
239
241
  - app/assets/javascripts/koi/controllers/document_field_controller.js
240
242
  - app/assets/javascripts/koi/controllers/file_field_controller.js
241
243
  - app/assets/javascripts/koi/controllers/flash_controller.js
@@ -260,6 +262,7 @@ files:
260
262
  - app/assets/stylesheets/koi/base/_list.scss
261
263
  - app/assets/stylesheets/koi/base/_typography.scss
262
264
  - app/assets/stylesheets/koi/components/_actions-group.scss
265
+ - app/assets/stylesheets/koi/components/_clipboard.scss
263
266
  - app/assets/stylesheets/koi/components/_document-field.scss
264
267
  - app/assets/stylesheets/koi/components/_image-field.scss
265
268
  - app/assets/stylesheets/koi/components/_index-actions.scss
@@ -326,10 +329,12 @@ files:
326
329
  - app/controllers/admin/credentials_controller.rb
327
330
  - app/controllers/admin/dashboards_controller.rb
328
331
  - app/controllers/admin/sessions_controller.rb
332
+ - app/controllers/admin/tokens_controller.rb
329
333
  - app/controllers/admin/url_rewrites_controller.rb
330
334
  - app/controllers/concerns/koi/controller/has_admin_users.rb
331
335
  - app/controllers/concerns/koi/controller/has_webauthn.rb
332
336
  - app/controllers/concerns/koi/controller/is_admin_controller.rb
337
+ - app/controllers/concerns/koi/controller/json_web_token.rb
333
338
  - app/helpers/koi/application_helper.rb
334
339
  - app/helpers/koi/date_helper.rb
335
340
  - app/helpers/koi/definition_list_helper.rb
@@ -357,6 +362,8 @@ files:
357
362
  - app/views/admin/shared/icons/_cross.html.erb
358
363
  - app/views/admin/shared/icons/_menu.html.erb
359
364
  - app/views/admin/shared/icons/_refresh.html.erb
365
+ - app/views/admin/tokens/create.turbo_stream.erb
366
+ - app/views/admin/tokens/show.html.erb
360
367
  - app/views/admin/url_rewrites/_fields.html.erb
361
368
  - app/views/admin/url_rewrites/_url_rewrite.html+row.erb
362
369
  - app/views/admin/url_rewrites/edit.html.erb
@@ -370,6 +377,7 @@ files:
370
377
  - app/views/katalyst/content/groups/_group.html+form.erb
371
378
  - app/views/katalyst/content/items/_item.html+form.erb
372
379
  - app/views/katalyst/content/sections/_section.html+form.erb
380
+ - app/views/katalyst/content/tables/_table.html+form.erb
373
381
  - app/views/katalyst/navigation/items/_button.html.erb
374
382
  - app/views/katalyst/navigation/items/_heading.html.erb
375
383
  - app/views/katalyst/navigation/items/_link.html.erb