iron-cms 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 49a31d47d6e5647a8f1f49bd18dfea621555969018b677b602c2d838d2e0bece
4
- data.tar.gz: 874b32467d3555cf4b2e0360f55f315536c6e10590aaf3f15af9dd707a27159e
3
+ metadata.gz: '0394a59fdfcd9e2552d9aea3fe48e8b95f7d01813841b0c91a0ba9fb2cc08220'
4
+ data.tar.gz: 6b1125a24a88c762fe24b73c6d50b0a0a35f76bc802d89f073c55fb58d4d063d
5
5
  SHA512:
6
- metadata.gz: 7a0ab6d523de2863a24b10a1b84cfba423fe198840f7a2bd28b5c951f1067ccc264b92c4ffdc11585a30ea2ab94d0b49479cb6dea39f7f4e0172d80ebe70c364
7
- data.tar.gz: 786d5f22ea259d34c1a4c733d8376cdc6048b071a762b28a34fab5b8759218542b618aeea91ef5973bf9630f0a86211b6c34c32bd34799355b2e08f4cb4ba59e
6
+ metadata.gz: dd37b2e6e68ccbb9993781cce2a0ccfbdf42ecd5247f36f010ff0ee1b97aed3228bc5cb4ac7961614617df8214985a950a8a67c86f904c83d341ce2a270449f0
7
+ data.tar.gz: cb47c4a18d861e37001015aeec7eff0f2d90ca7ba8fd7ca7daeb85e03387fbb39ea84e3104e5a4e86e99c1986f3ea6656398f63e4e516582f80b9260655b1dc0
@@ -49,6 +49,7 @@
49
49
  --container-2xs: 18rem;
50
50
  --container-xs: 20rem;
51
51
  --container-sm: 24rem;
52
+ --container-md: 28rem;
52
53
  --container-2xl: 42rem;
53
54
  --container-4xl: 56rem;
54
55
  --text-xs: 0.75rem;
@@ -1839,6 +1840,9 @@
1839
1840
  .table {
1840
1841
  display: table;
1841
1842
  }
1843
+ .aspect-square {
1844
+ aspect-ratio: 1 / 1;
1845
+ }
1842
1846
  .size-4 {
1843
1847
  width: calc(var(--spacing) * 4);
1844
1848
  height: calc(var(--spacing) * 4);
@@ -1855,6 +1859,10 @@
1855
1859
  width: calc(var(--spacing) * 8);
1856
1860
  height: calc(var(--spacing) * 8);
1857
1861
  }
1862
+ .size-auto {
1863
+ width: auto;
1864
+ height: auto;
1865
+ }
1858
1866
  .size-full {
1859
1867
  width: 100%;
1860
1868
  height: 100%;
@@ -1886,6 +1894,9 @@
1886
1894
  .max-h-60 {
1887
1895
  max-height: calc(var(--spacing) * 60);
1888
1896
  }
1897
+ .max-h-none {
1898
+ max-height: none;
1899
+ }
1889
1900
  .min-h-56 {
1890
1901
  min-height: calc(var(--spacing) * 56);
1891
1902
  }
@@ -1940,6 +1951,9 @@
1940
1951
  .max-w-96 {
1941
1952
  max-width: calc(var(--spacing) * 96);
1942
1953
  }
1954
+ .max-w-md {
1955
+ max-width: var(--container-md);
1956
+ }
1943
1957
  .max-w-none {
1944
1958
  max-width: none;
1945
1959
  }
@@ -2063,6 +2077,13 @@
2063
2077
  margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
2064
2078
  }
2065
2079
  }
2080
+ .space-y-2 {
2081
+ :where(& > :not(:last-child)) {
2082
+ --tw-space-y-reverse: 0;
2083
+ margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
2084
+ margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
2085
+ }
2086
+ }
2066
2087
  .space-y-4 {
2067
2088
  :where(& > :not(:last-child)) {
2068
2089
  --tw-space-y-reverse: 0;
@@ -2277,6 +2298,12 @@
2277
2298
  }
2278
2299
  }
2279
2300
  }
2301
+ .bg-black\/50 {
2302
+ background-color: color-mix(in srgb, #000 50%, transparent);
2303
+ @supports (color: color-mix(in lab, red, red)) {
2304
+ background-color: color-mix(in oklab, var(--color-black) 50%, transparent);
2305
+ }
2306
+ }
2280
2307
  .bg-gray-100 {
2281
2308
  background-color: var(--color-gray-100);
2282
2309
  }
@@ -2328,6 +2355,9 @@
2328
2355
  background-color: color-mix(in oklab, var(--color-stone-900) 80%, transparent);
2329
2356
  }
2330
2357
  }
2358
+ .bg-transparent {
2359
+ background-color: transparent;
2360
+ }
2331
2361
  .bg-white {
2332
2362
  background-color: var(--color-white);
2333
2363
  }
@@ -2902,6 +2932,16 @@
2902
2932
  opacity: 0%;
2903
2933
  }
2904
2934
  }
2935
+ .group-\[\.success\]\:hidden {
2936
+ &:is(:where(.group):is(.success) *) {
2937
+ display: none;
2938
+ }
2939
+ }
2940
+ .group-\[\.success\]\:inline {
2941
+ &:is(:where(.group):is(.success) *) {
2942
+ display: inline;
2943
+ }
2944
+ }
2905
2945
  .peer-has-\[\:checked\]\:block {
2906
2946
  &:is(:where(.peer):has(*:is(:checked)) ~ *) {
2907
2947
  display: block;
@@ -2981,6 +3021,13 @@
2981
3021
  }
2982
3022
  }
2983
3023
  }
3024
+ .hover\:opacity-75 {
3025
+ &:hover {
3026
+ @media (hover: hover) {
3027
+ opacity: 75%;
3028
+ }
3029
+ }
3030
+ }
2984
3031
  .focus\:bg-sky-100 {
2985
3032
  &:focus {
2986
3033
  background-color: var(--color-sky-100);
@@ -3100,6 +3147,12 @@
3100
3147
  transition-duration: 100ms;
3101
3148
  }
3102
3149
  }
3150
+ .data-enter\:duration-300 {
3151
+ &[data-enter] {
3152
+ --tw-duration: 300ms;
3153
+ transition-duration: 300ms;
3154
+ }
3155
+ }
3103
3156
  .data-enter\:ease-out {
3104
3157
  &[data-enter] {
3105
3158
  --tw-ease: var(--ease-out);
@@ -3130,6 +3183,12 @@
3130
3183
  transition-duration: 100ms;
3131
3184
  }
3132
3185
  }
3186
+ .data-leave\:duration-200 {
3187
+ &[data-leave] {
3188
+ --tw-duration: 200ms;
3189
+ transition-duration: 200ms;
3190
+ }
3191
+ }
3133
3192
  .data-leave\:ease-in {
3134
3193
  &[data-leave] {
3135
3194
  --tw-ease: var(--ease-in);
@@ -2,6 +2,7 @@ module Iron
2
2
  class PasswordsController < ApplicationController
3
3
  layout "iron/authentication"
4
4
  allow_unauthenticated_access
5
+ before_action :require_email_configuration, only: %i[ new create ]
5
6
  before_action :set_user_by_token, only: %i[ edit update ]
6
7
 
7
8
  def new
@@ -9,7 +10,7 @@ module Iron
9
10
 
10
11
  def create
11
12
  if user = User.find_by(email_address: params[:email_address])
12
- PasswordsMailer.reset(user).deliver_later
13
+ Iron::PasswordsMailer.reset(user).deliver_later
13
14
  end
14
15
 
15
16
  redirect_to new_session_url, notice: "Password reset instructions sent (if user with that email address exists)."
@@ -27,6 +28,12 @@ module Iron
27
28
  end
28
29
 
29
30
  private
31
+ def require_email_configuration
32
+ unless EmailConfiguration.available?
33
+ redirect_to sign_in_url, alert: "Email is not configured. Please contact an administrator for a transfer link."
34
+ end
35
+ end
36
+
30
37
  def set_user_by_token
31
38
  @user = User.find_by_password_reset_token!(params[:token])
32
39
  rescue ActiveSupport::MessageVerifier::InvalidSignature
@@ -0,0 +1,15 @@
1
+ module Iron
2
+ class QrCodesController < ApplicationController
3
+ allow_unauthenticated_access
4
+
5
+ def show
6
+ qr_code_link = QrCodeLink.from_signed(params[:id])
7
+ svg = RQRCode::QRCode.new(qr_code_link.url).as_svg(viewbox: true, fill: :white, color: :black)
8
+
9
+ expires_in 1.year, public: true
10
+ render plain: svg, content_type: "image/svg+xml"
11
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
12
+ head :bad_request
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ module Iron
2
+ module Sessions
3
+ class TransfersController < ApplicationController
4
+ layout "iron/authentication"
5
+ allow_unauthenticated_access
6
+ rate_limit to: 10, within: 3.minutes, only: :update, with: -> { redirect_to sign_in_url, alert: "Try again later." }
7
+
8
+ def show
9
+ end
10
+
11
+ def update
12
+ if user = User.find_by_transfer_id(params[:id])
13
+ start_new_session_for user
14
+ redirect_to after_authentication_url
15
+ else
16
+ redirect_to sign_in_url, alert: "Transfer link is invalid or has expired."
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -3,9 +3,10 @@ module Iron
3
3
  layout "iron/authentication", only: %i[ new create ]
4
4
  require_unauthenticated_access only: %i[ new create ]
5
5
 
6
- before_action :set_user, only: %i[ show update destroy ]
6
+ before_action :set_user, only: %i[ show edit update destroy ]
7
7
  before_action :verify_join_code, only: %i[ new create ]
8
- before_action :ensure_can_administer, except: %i[ new create ]
8
+ before_action :ensure_can_administer, only: %i[ index destroy ]
9
+ before_action :ensure_current_user, only: %i[ edit ]
9
10
 
10
11
  def index
11
12
  @users = User.all
@@ -18,7 +19,10 @@ module Iron
18
19
  def show
19
20
  end
20
21
 
21
- def create
22
+ def edit
23
+ end
24
+
25
+ def create
22
26
  @user = User.new(join_params)
23
27
  if @user.save
24
28
  start_new_session_for @user
@@ -31,10 +35,10 @@ module Iron
31
35
  end
32
36
 
33
37
  def update
34
- if @user.update(update_params)
35
- redirect_back fallback_location: users_path, notice: "User was successfully updated.", status: :see_other
38
+ if @user.current?
39
+ update_own_profile
36
40
  else
37
- redirect_back fallback_location: users_path, alert: @user.errors.full_messages.to_sentence, status: :see_other
41
+ update_user_role
38
42
  end
39
43
  end
40
44
 
@@ -55,12 +59,36 @@ module Iron
55
59
  params.expect(user: [ :email_address, :password, :password_confirmation ])
56
60
  end
57
61
 
58
- def update_params
59
- params.expect(user: [ :email_address, :role ])
62
+ def profile_params
63
+ params.require(:user).permit(:email_address, :password, :password_confirmation)
64
+ end
65
+
66
+ def role_params
67
+ params.expect(user: [ :role ])
60
68
  end
61
69
 
62
70
  def verify_join_code
63
71
  head :not_found if Current.account.join_code != params[:join_code]
64
72
  end
73
+
74
+ def ensure_current_user
75
+ redirect_to @user, alert: "You can only edit your own profile." unless @user.current?
76
+ end
77
+
78
+ def update_own_profile
79
+ if @user.update(profile_params)
80
+ redirect_to @user, notice: "Profile updated."
81
+ else
82
+ render :edit, status: :unprocessable_entity
83
+ end
84
+ end
85
+
86
+ def update_user_role
87
+ if @user.update(role_params)
88
+ redirect_back fallback_location: users_path, notice: "User was successfully updated.", status: :see_other
89
+ else
90
+ redirect_back fallback_location: users_path, alert: @user.errors.full_messages.to_sentence, status: :see_other
91
+ end
92
+ end
65
93
  end
66
94
  end
@@ -1,7 +1,7 @@
1
1
  require "tailwind_merge"
2
2
  module Iron
3
3
  module ApplicationHelper
4
- include IconsHelper, AvatarHelper, UiHelper, EntriesHelper, ContentTypesHelper
4
+ include IconsHelper, AvatarHelper, UiHelper, EntriesHelper, ContentTypesHelper, TransfersHelper
5
5
 
6
6
  TAILWIND_MERGER = TailwindMerge::Merger.new.freeze
7
7
 
@@ -12,5 +12,9 @@ module Iron
12
12
  def unique_id
13
13
  rand(10000000..100000000)
14
14
  end
15
+
16
+ def email_configured?
17
+ EmailConfiguration.available?
18
+ end
15
19
  end
16
20
  end
@@ -0,0 +1,27 @@
1
+ module Iron
2
+ module TransfersHelper
3
+ def button_to_copy_to_clipboard(url, &block)
4
+ tag.button class: "button-secondary group", data: {
5
+ controller: "copy-to-clipboard",
6
+ action: "copy-to-clipboard#copy",
7
+ copy_to_clipboard_content_value: url,
8
+ copy_to_clipboard_success_class: "success"
9
+ }, &block
10
+ end
11
+
12
+ def web_share_button(url, title, text, &block)
13
+ tag.button class: "button-secondary", hidden: true, data: {
14
+ controller: "web-share",
15
+ action: "web-share#share",
16
+ web_share_url_value: url,
17
+ web_share_text_value: text,
18
+ web_share_title_value: title
19
+ }, &block
20
+ end
21
+
22
+ def qr_code_image(url)
23
+ qr_code_link = QrCodeLink.new(url)
24
+ image_tag qr_code_path(qr_code_link.signed), class: "aspect-square", alt: "QR Code"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ content: String,
6
+ successDuration: { type: Number, default: 1500 }
7
+ }
8
+ static classes = ["success"]
9
+
10
+ async copy(event) {
11
+ event.preventDefault()
12
+ this.reset()
13
+
14
+ try {
15
+ await navigator.clipboard.writeText(this.contentValue)
16
+ this.element.classList.add(this.successClass)
17
+ setTimeout(() => this.reset(), this.successDurationValue)
18
+ } catch { }
19
+ }
20
+
21
+ reset() {
22
+ this.element.classList.remove(this.successClass)
23
+ this.#forceReflow()
24
+ }
25
+
26
+ #forceReflow() {
27
+ this.element.offsetWidth
28
+ }
29
+ }
@@ -0,0 +1,17 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = { title: String, text: String, url: String }
5
+
6
+ connect() {
7
+ this.element.hidden = !navigator.canShare
8
+ }
9
+
10
+ async share() {
11
+ await navigator.share({
12
+ title: this.titleValue,
13
+ text: this.textValue,
14
+ url: this.urlValue
15
+ })
16
+ }
17
+ }
@@ -0,0 +1,8 @@
1
+ module Iron
2
+ class PasswordsMailer < ApplicationMailer
3
+ def reset(user)
4
+ @user = user
5
+ mail subject: "Reset your password", to: user.email_address
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ module Iron
2
+ class EmailConfiguration
3
+ def self.available?
4
+ delivery_method = ActionMailer::Base.delivery_method
5
+
6
+ case delivery_method
7
+ when :smtp
8
+ ActionMailer::Base.smtp_settings[:address].present?
9
+ when :sendmail, :file
10
+ true
11
+ when :test
12
+ Rails.env.test?
13
+ else
14
+ # Allow other delivery methods (postmark, sendgrid, etc.)
15
+ true
16
+ end
17
+ rescue
18
+ false
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,28 @@
1
+ module Iron
2
+ class QrCodeLink
3
+ attr_reader :url
4
+
5
+ def initialize(url)
6
+ @url = url
7
+ end
8
+
9
+ def signed
10
+ self.class.verifier.generate(@url, purpose: :qr_code)
11
+ end
12
+
13
+ def self.from_signed(signed)
14
+ new verifier.verify(signed, purpose: :qr_code)
15
+ end
16
+
17
+ private
18
+ class << self
19
+ def verifier
20
+ ActiveSupport::MessageVerifier.new(secret, url_safe: true)
21
+ end
22
+
23
+ def secret
24
+ Rails.application.key_generator.generate_key("iron_qr_codes")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ module Iron::User::Transferable
2
+ extend ActiveSupport::Concern
3
+
4
+ TRANSFER_LINK_EXPIRY_DURATION = 4.hours
5
+
6
+ class_methods do
7
+ def find_by_transfer_id(id)
8
+ find_signed(id, purpose: :transfer)
9
+ end
10
+ end
11
+
12
+ def transfer_id
13
+ signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
14
+ end
15
+ end
@@ -1,6 +1,7 @@
1
1
  module Iron
2
2
  class User < ApplicationRecord
3
3
  include Role
4
+ include Transferable
4
5
 
5
6
  before_destroy :ensure_not_current_user
6
7
 
@@ -18,9 +18,11 @@
18
18
  <div>
19
19
  <div class="flex items-center justify-between">
20
20
  <%= form.label :password, "Password", class: "block text-sm/6 font-medium text-stone-900 dark:text-stone-100" %>
21
- <div class="text-sm">
22
- <%= link_to "Forgot password?", new_password_path, class: "font-semibold text-stone-600 hover:text-stone-500 dark:text-stone-400 dark:hover:text-stone-300" %>
23
- </div>
21
+ <% if email_configured? %>
22
+ <div class="text-sm">
23
+ <%= link_to "Forgot password?", new_password_path, class: "font-semibold text-stone-600 hover:text-stone-500 dark:text-stone-400 dark:hover:text-stone-300" %>
24
+ </div>
25
+ <% end %>
24
26
  </div>
25
27
  <div class="mt-2">
26
28
  <%= form.password_field :password,
@@ -0,0 +1,16 @@
1
+ <div class="sm:mx-auto sm:w-full sm:max-w-sm">
2
+ <h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-stone-900 dark:text-white">Confirm sign in</h2>
3
+ <p class="mt-2 text-center text-sm/6 text-stone-500 dark:text-stone-400">You're about to sign in using a transfer link</p>
4
+ </div>
5
+
6
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
7
+ <%= form_with url: session_transfer_path(params[:id]), method: :put, class: "space-y-6" do |form| %>
8
+ <div>
9
+ <%= form.submit "Sign in", class: "button-primary w-full" %>
10
+ </div>
11
+ <% end %>
12
+
13
+ <p class="mt-10 text-center text-sm/6 text-stone-500 dark:text-stone-400">
14
+ <%= link_to "Back to sign in", sign_in_path, class: "font-semibold text-stone-600 hover:text-stone-500 dark:text-stone-400 dark:hover:text-stone-300" %>
15
+ </p>
16
+ </div>
@@ -0,0 +1,56 @@
1
+ <% url = join_url(Iron::Current.account.join_code) %>
2
+
3
+ <div class="max-w-md">
4
+ <div class="bg-white dark:bg-stone-800/50 rounded-lg shadow-sm p-6 space-y-4">
5
+ <div class="space-y-2">
6
+ <h3 class="text-sm font-medium text-stone-900 dark:text-white">Invite Users</h3>
7
+ <p class="text-sm text-stone-500 dark:text-stone-400">Share this link to invite people to join</p>
8
+ </div>
9
+
10
+ <div class="field">
11
+ <label for="join-url" class="sr-only">Invitation URL</label>
12
+ <input type="text" id="join-url" class="input" value="<%= url %>" readonly>
13
+ </div>
14
+
15
+ <div class="flex items-center gap-2">
16
+ <div>
17
+ <button command="show-modal" commandfor="invite-qr-code-dialog" class="button-secondary">
18
+ <%= icon "qr-code", class: "size-4" %>
19
+ <span>Show QR code</span>
20
+ </button>
21
+
22
+ <el-dialog>
23
+ <dialog id="invite-qr-code-dialog" class="fixed inset-0 size-auto max-h-none max-w-none overflow-y-auto bg-transparent backdrop:bg-transparent">
24
+ <el-dialog-backdrop class="fixed inset-0 bg-black/50 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"></el-dialog-backdrop>
25
+
26
+ <div tabindex="0" class="flex min-h-full items-center justify-center p-4 focus:outline-none">
27
+ <el-dialog-panel class="relative transform overflow-hidden rounded-lg bg-white p-6 shadow-xl transition-all data-closed:scale-95 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-stone-800">
28
+ <div class="flex flex-col items-center gap-4">
29
+ <div class="w-64">
30
+ <%= qr_code_image(url) %>
31
+ </div>
32
+ <button type="button" command="close" commandfor="invite-qr-code-dialog" class="button-secondary">
33
+ <%= icon "x", class: "size-4" %>
34
+ <span>Close</span>
35
+ </button>
36
+ </div>
37
+ </el-dialog-panel>
38
+ </div>
39
+ </dialog>
40
+ </el-dialog>
41
+ </div>
42
+
43
+ <%= button_to_copy_to_clipboard(url) do %>
44
+ <%= icon "copy", class: "size-4 group-[.success]:hidden" %>
45
+ <%= icon "check", class: "size-4 hidden group-[.success]:inline" %>
46
+ <span class="group-[.success]:hidden">Copy</span>
47
+ <span class="hidden group-[.success]:inline">Copied</span>
48
+ <% end %>
49
+
50
+ <%= web_share_button(url, "Join invitation", "Use this link to join as a new user.") do %>
51
+ <%= icon "share", class: "size-4" %>
52
+ <span>Share</span>
53
+ <% end %>
54
+ </div>
55
+ </div>
56
+ </div>
@@ -0,0 +1,65 @@
1
+ <%# locals: (user:) -%>
2
+ <% url = session_transfer_url(user.transfer_id) %>
3
+
4
+ <div class="space-y-4">
5
+ <div class="space-y-2">
6
+ <% if Iron::Current.user != user %>
7
+ <h3 class="text-sm font-medium text-stone-900 dark:text-white">Account Recovery Link</h3>
8
+ <p class="text-sm text-stone-500 dark:text-stone-400">Share this link to help them sign in on another device</p>
9
+ <% else %>
10
+ <h3 class="text-sm font-medium text-stone-900 dark:text-white">Transfer Session</h3>
11
+ <p class="text-sm text-stone-500 dark:text-stone-400">Use this link to automatically sign in on another device</p>
12
+ <% end %>
13
+ </div>
14
+
15
+ <div class="field">
16
+ <label for="session_transfer_url" class="sr-only">Auto-login URL</label>
17
+ <input type="text" id="session_transfer_url" value="<%= url %>" readonly class="input">
18
+ <p class="text-xs text-stone-400 dark:text-stone-500 mt-2">
19
+ This link expires in 4 hours.
20
+ </p>
21
+ </div>
22
+
23
+
24
+ <div class="flex items-center gap-2">
25
+ <div>
26
+ <button command="show-modal" commandfor="qr-code-dialog" class="button-secondary">
27
+ <%= icon "qr-code", class: "size-4" %>
28
+ <span class="">Show QR code</span>
29
+ </button>
30
+
31
+ <el-dialog>
32
+ <dialog id="qr-code-dialog" class="fixed inset-0 size-auto max-h-none max-w-none overflow-y-auto bg-transparent backdrop:bg-transparent">
33
+ <el-dialog-backdrop class="fixed inset-0 bg-black/50 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"></el-dialog-backdrop>
34
+
35
+ <div tabindex="0" class="flex min-h-full items-center justify-center p-4 focus:outline-none">
36
+ <el-dialog-panel class="relative transform overflow-hidden rounded-lg bg-white p-6 shadow-xl transition-all data-closed:scale-95 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-stone-800">
37
+ <div class="flex flex-col items-center gap-4">
38
+ <div class="w-64">
39
+ <%= qr_code_image(url) %>
40
+ </div>
41
+ <button type="button" command="close" commandfor="qr-code-dialog" class="button-secondary">
42
+ <%= icon "x", class: "size-4" %>
43
+ <span>Close</span>
44
+ </button>
45
+ </div>
46
+ </el-dialog-panel>
47
+ </div>
48
+ </dialog>
49
+ </el-dialog>
50
+ </div>
51
+
52
+ <%= button_to_copy_to_clipboard(url) do %>
53
+ <%= icon "copy", class: "size-4 group-[.success]:hidden" %>
54
+ <%= icon "check", class: "size-4 hidden group-[.success]:inline" %>
55
+ <span class="group-[.success]:hidden">Copy</span>
56
+ <span class="hidden group-[.success]:inline">Copied</span>
57
+ <% end %>
58
+
59
+ <%= web_share_button(url, "Your sign-in link", "This is your private sign-in URL. Use it to sign in on another device.") do %>
60
+ <%= icon "share", class: "size-4" %>
61
+ <span class="">Share link</span>
62
+ <% end %>
63
+ </div>
64
+
65
+ </div>
@@ -2,12 +2,12 @@
2
2
  <div class="block px-6 py-3">
3
3
  <div class="flex items-center justify-between gap-4">
4
4
  <div class="flex flex-col sm:flex-row sm:items-center sm:gap-8 min-w-0 flex-1">
5
- <div class="flex items-center gap-3 shrink-0">
5
+ <%= link_to user_path(user), class: "flex items-center gap-3 shrink-0 hover:opacity-75 transition-opacity" do %>
6
6
  <h3 class="text-base font-semibold text-stone-900 dark:text-white truncate"><%= user.name %></h3>
7
7
  <span class="text-sm text-stone-400"><%= user.email_address %></span>
8
- </div>
8
+ <% end %>
9
9
  </div>
10
- <div>
10
+ <div class="flex items-center gap-2">
11
11
  <% if user.current? %>
12
12
  <%= badge user.role, class: ("badge-blue" if user.administrator?) %>
13
13
  <% else %>
@@ -20,6 +20,10 @@
20
20
  </div>
21
21
  </div>
22
22
  <% end %>
23
+ <%= button_to user_path(user), method: :delete, class: "button-destructive-icon", data: { turbo_confirm: "Are you sure you want to remove #{user.name}?" } do %>
24
+ <%= icon "trash-2", class: "size-4" %>
25
+ <span class="sr-only">Remove <%= user.name %></span>
26
+ <% end %>
23
27
  <% end %>
24
28
  </div>
25
29
  </div>
@@ -1,12 +1,49 @@
1
- <% content_for :title, "Editing user" %>
1
+ <% content_for :title, "Edit Profile" %>
2
2
 
3
- <h1>Editing user</h1>
3
+ <div class="space-y-8">
4
+ <%= back_button_to "My Account", user_path(@user) %>
4
5
 
5
- <%= render "form", user: @user %>
6
+ <div class="flex justify-between items-center">
7
+ <h1 class="page-title">Edit Profile</h1>
8
+ </div>
6
9
 
7
- <br>
10
+ <div class="max-w-md">
11
+ <%= form_with(model: @user, class: "space-y-6") do |form| %>
12
+ <div class="bg-white dark:bg-stone-800/50 rounded-lg shadow-sm p-6 space-y-4">
13
+ <div class="field">
14
+ <%= form.label :email_address, "Email" %>
15
+ <div>
16
+ <%= form.email_field :email_address, autocomplete: "email" %>
17
+ <%= form.error_for :email_address %>
18
+ </div>
19
+ </div>
20
+ </div>
8
21
 
9
- <div>
10
- <%= link_to "Show this user", @user %> |
11
- <%= link_to "Back to users", users_path %>
22
+ <div class="bg-white dark:bg-stone-800/50 rounded-lg shadow-sm p-6 space-y-4">
23
+ <h2 class="text-sm font-medium text-stone-900 dark:text-white">Change Password</h2>
24
+ <p class="text-sm text-stone-500 dark:text-stone-400">Leave blank to keep your current password</p>
25
+
26
+ <div class="field">
27
+ <%= form.label :password, "New password" %>
28
+ <div>
29
+ <%= form.password_field :password, autocomplete: "new-password", maxlength: 72 %>
30
+ <%= form.error_for :password %>
31
+ </div>
32
+ </div>
33
+
34
+ <div class="field">
35
+ <%= form.label :password_confirmation, "Confirm new password" %>
36
+ <div>
37
+ <%= form.password_field :password_confirmation, autocomplete: "new-password", maxlength: 72 %>
38
+ <%= form.error_for :password_confirmation %>
39
+ </div>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="flex gap-3">
44
+ <%= form.submit "Save changes", class: "button-primary" %>
45
+ <%= link_to "Cancel", user_path(@user), class: "button-secondary" %>
46
+ </div>
47
+ <% end %>
48
+ </div>
12
49
  </div>
@@ -1,19 +1,11 @@
1
1
  <% content_for :title, "Users" %>
2
2
 
3
- <% url = join_url(Iron::Current.account.join_code) %>
4
-
5
3
  <div class="space-y-8">
6
4
  <div class="flex justify-between items-center">
7
5
  <h1 class="page-title">Users</h1>
8
6
  </div>
9
7
 
10
- <div>
11
- <h2>Invite users</h2>
12
- <div class="field">
13
- <label for="join-url">Share to invite people to join</label>
14
- <input type="text" id="join-url" class="input" value="<%= url %>" readonly>
15
- </div>
16
- </div>
8
+ <%= render "iron/users/invite" %>
17
9
 
18
10
  <div id="users" class="bg-white dark:bg-stone-800/50 rounded-lg shadow-sm divide-y divide-stone-200 dark:divide-stone-700/20 overflow-hidden">
19
11
  <%= render @users %>
@@ -1,29 +1,37 @@
1
1
  <% content_for :title, "Join #{Iron::Current.account.name}" %>
2
2
 
3
- <div>
4
- <div class="mb-8 text-center">
5
- <h1 class="text-2xl font-semibold tracking-tight">Join <%= Iron::Current.account.name %></h1>
6
- </div>
3
+ <div class="sm:mx-auto sm:w-full sm:max-w-sm">
4
+ <h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-stone-900 dark:text-white">Join <%= Iron::Current.account.name %></h2>
5
+ </div>
7
6
 
8
- <%= form_with(model: @user, url: join_path(params[:join_code]), class: "space-y-4") do |form| %>
7
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
8
+ <%= form_with(model: @user, url: join_path(params[:join_code]), class: "space-y-6") do |form| %>
9
9
  <div>
10
- <%= form.label :email_address %>
11
- <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "email" %>
10
+ <%= form.label :email_address, "Email address", class: "block text-sm/6 font-medium text-stone-900 dark:text-stone-100" %>
11
+ <div class="mt-2">
12
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "email" %>
13
+ </div>
12
14
  <%= form.error_for :email_address %>
13
15
  </div>
14
16
 
15
17
  <div>
16
- <%= form.label :password %>
17
- <%= form.password_field :password, required: true, autocomplete: "new-password" %>
18
+ <%= form.label :password, "Password", class: "block text-sm/6 font-medium text-stone-900 dark:text-stone-100" %>
19
+ <div class="mt-2">
20
+ <%= form.password_field :password, required: true, autocomplete: "new-password" %>
21
+ </div>
18
22
  <%= form.error_for :password %>
19
23
  </div>
20
24
 
21
25
  <div>
22
- <%= form.label :password_confirmation %>
23
- <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password" %>
26
+ <%= form.label :password_confirmation, "Password confirmation", class: "block text-sm/6 font-medium text-stone-900 dark:text-stone-100" %>
27
+ <div class="mt-2">
28
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password" %>
29
+ </div>
24
30
  <%= form.error_for :password_confirmation %>
25
31
  </div>
26
32
 
27
- <%= form.submit "Join", class: "button-primary mt-4 w-full" %>
33
+ <div>
34
+ <%= form.submit "Join", class: "button-primary w-full" %>
35
+ </div>
28
36
  <% end %>
29
37
  </div>
@@ -1,18 +1,39 @@
1
- <% content_for :title, "#{@user.name}" %>
1
+ <% content_for :title, @user.current? ? "My Account" : @user.name %>
2
2
 
3
- <div id="<%= dom_id @user %>">
4
- <%= back_button_to "Users", users_path %>
5
- <div class="flex justify-between">
6
- <h1 class="page-title"><%= @user.name %></h1>
7
- <div>
8
- <%= unless @user.current?
9
- button_to "Destroy",
10
- @user,
11
- method: :delete,
12
- class: "button-destructive"
13
- end %>
14
- </div>
3
+ <div class="space-y-8">
4
+ <% unless @user.current? %>
5
+ <%= back_button_to "Users", users_path %>
6
+ <% end %>
7
+
8
+ <div class="flex justify-between items-center">
9
+ <h1 class="page-title"><%= @user.current? ? "My Account" : @user.name %></h1>
10
+ <% if @user.current? %>
11
+ <%= link_to "Edit", edit_user_path(@user), class: "button-secondary" %>
12
+ <% end %>
15
13
  </div>
16
14
 
17
- <%= render "form", user: @user %>
15
+ <div class="max-w-md space-y-6">
16
+ <div class="bg-white dark:bg-stone-800/50 rounded-lg shadow-sm p-6">
17
+ <div class="space-y-4">
18
+ <div class="field">
19
+ <label class="label">Email</label>
20
+ <p class="text-stone-900 dark:text-white"><%= @user.email_address %></p>
21
+ </div>
22
+ <div class="field">
23
+ <label class="label">Role</label>
24
+ <p class="text-stone-900 dark:text-white"><%= @user.role.titleize %></p>
25
+ </div>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="bg-white dark:bg-stone-800/50 rounded-lg shadow-sm p-6">
30
+ <%= render "iron/users/transfer", user: @user %>
31
+ </div>
32
+
33
+ <% if @user.current? %>
34
+ <div class="bg-white dark:bg-stone-800/50 rounded-lg shadow-sm p-6">
35
+ <%= button_to "Sign out", session_path, method: :delete, class: "button-outline w-full" %>
36
+ </div>
37
+ <% end %>
38
+ </div>
18
39
  </div>
@@ -11,10 +11,10 @@
11
11
 
12
12
  <el-menu anchor="<%= anchor %>" popover class="<%= anchor.include?('top') ? 'mx-2' : '' %> w-56 origin-bottom-right divide-y divide-stone-100 rounded-md bg-white shadow-lg outline-1 outline-black/5 transition transition-discrete [--anchor-gap:--spacing(2)] data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in dark:divide-white/10 dark:bg-stone-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10">
13
13
  <div class="py-1">
14
- <a href="#" class="group/item flex items-center px-4 py-2 text-sm text-stone-700 focus:bg-stone-100 focus:text-stone-900 focus:outline-hidden dark:text-stone-300 dark:focus:bg-white/5 dark:focus:text-white">
14
+ <%= link_to user_path(Iron::Current.user), class: "group/item flex items-center px-4 py-2 text-sm text-stone-700 focus:bg-stone-100 focus:text-stone-900 focus:outline-hidden dark:text-stone-300 dark:focus:bg-white/5 dark:focus:text-white" do %>
15
15
  <%= icon "circle-user", class: "mr-3 size-5 text-stone-400 group-focus/item:text-stone-500 dark:text-stone-500 dark:group-focus/item:text-white" %>
16
16
  My account
17
- </a>
17
+ <% end %>
18
18
  </div>
19
19
  <div class="py-1">
20
20
  <%= button_to session_path, method: :delete, class: "group/item w-full flex items-center px-4 py-2 text-sm text-stone-700 focus:bg-stone-100 focus:text-stone-900 focus:outline-hidden dark:text-stone-300 dark:focus:bg-white/5 dark:focus:text-white" do %>
data/config/routes.rb CHANGED
@@ -27,13 +27,18 @@ Iron::Engine.routes.draw do
27
27
  resources :locales, except: %i[ show ]
28
28
 
29
29
  resource :settings, only: %i[ show update ]
30
- resources :users, only: %i[ index show update destroy ]
30
+ resources :users, only: %i[ index show edit update destroy ]
31
31
 
32
32
  # Authentication
33
33
  get "join/:join_code", to: "users#new", as: :join
34
34
  post "join/:join_code", to: "users#create"
35
35
  get "sign_in", to: "sessions#new"
36
36
  post "sign_in", to: "sessions#create"
37
- resource :session, only: %i[ destroy ]
37
+ resource :session, only: %i[ destroy ] do
38
+ scope module: "sessions" do
39
+ resources :transfers, only: %i[ show update ]
40
+ end
41
+ end
38
42
  resources :passwords, param: :token
43
+ resources :qr_codes, only: :show
39
44
  end
data/lib/iron/engine.rb CHANGED
@@ -6,6 +6,7 @@ require "action_text/engine"
6
6
  require "lexxy"
7
7
  require "bcrypt"
8
8
  require "ostruct"
9
+ require "rqrcode"
9
10
  require "iron/lexorank"
10
11
  require "iron/sdk"
11
12
  require "iron/routing"
data/lib/iron/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Iron
2
- VERSION = "0.7.1"
2
+ VERSION = "0.8.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iron-cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Massimo De Marchi
@@ -191,6 +191,20 @@ dependencies:
191
191
  - - "~>"
192
192
  - !ruby/object:Gem::Version
193
193
  version: '2.3'
194
+ - !ruby/object:Gem::Dependency
195
+ name: rqrcode
196
+ requirement: !ruby/object:Gem::Requirement
197
+ requirements:
198
+ - - "~>"
199
+ - !ruby/object:Gem::Version
200
+ version: '2.2'
201
+ type: :runtime
202
+ prerelease: false
203
+ version_requirements: !ruby/object:Gem::Requirement
204
+ requirements:
205
+ - - "~>"
206
+ - !ruby/object:Gem::Version
207
+ version: '2.2'
194
208
  description: A Rails engine for managing and publishing content with a flexible schema.
195
209
  email:
196
210
  - massimo@inkofpixel.com
@@ -236,7 +250,9 @@ files:
236
250
  - app/controllers/iron/icons_controller.rb
237
251
  - app/controllers/iron/locales_controller.rb
238
252
  - app/controllers/iron/passwords_controller.rb
253
+ - app/controllers/iron/qr_codes_controller.rb
239
254
  - app/controllers/iron/references_controller.rb
255
+ - app/controllers/iron/sessions/transfers_controller.rb
240
256
  - app/controllers/iron/sessions_controller.rb
241
257
  - app/controllers/iron/settings_controller.rb
242
258
  - app/controllers/iron/users_controller.rb
@@ -248,9 +264,11 @@ files:
248
264
  - app/helpers/iron/form_builder.rb
249
265
  - app/helpers/iron/icons_helper.rb
250
266
  - app/helpers/iron/image_helper.rb
267
+ - app/helpers/iron/transfers_helper.rb
251
268
  - app/helpers/iron/ui_helper.rb
252
269
  - app/javascript/iron/application.js
253
270
  - app/javascript/iron/controllers/application.js
271
+ - app/javascript/iron/controllers/copy_to_clipboard_controller.js
254
272
  - app/javascript/iron/controllers/element_controller.js
255
273
  - app/javascript/iron/controllers/file_upload_controller.js
256
274
  - app/javascript/iron/controllers/form_controller.js
@@ -260,13 +278,14 @@ files:
260
278
  - app/javascript/iron/controllers/local_preference_controller.js
261
279
  - app/javascript/iron/controllers/sortable_list_controller.js
262
280
  - app/javascript/iron/controllers/toggle_controller.js
281
+ - app/javascript/iron/controllers/web_share_controller.js
263
282
  - app/javascript/iron/lib/lexorank.js
264
283
  - app/jobs/iron/application_job.rb
265
284
  - app/jobs/iron/export_job.rb
266
285
  - app/jobs/iron/generate_entry_routes_job.rb
267
286
  - app/jobs/iron/import_job.rb
268
287
  - app/mailers/iron/application_mailer.rb
269
- - app/mailers/passwords_mailer.rb
288
+ - app/mailers/iron/passwords_mailer.rb
270
289
  - app/models/concerns/iron/broadcastable.rb
271
290
  - app/models/concerns/iron/instance_scoped.rb
272
291
  - app/models/concerns/iron/processable.rb
@@ -287,6 +306,7 @@ files:
287
306
  - app/models/iron/content_types/collection.rb
288
307
  - app/models/iron/content_types/single.rb
289
308
  - app/models/iron/current.rb
309
+ - app/models/iron/email_configuration.rb
290
310
  - app/models/iron/entry.rb
291
311
  - app/models/iron/entry/deep_validation.rb
292
312
  - app/models/iron/entry/exportable.rb
@@ -325,10 +345,12 @@ files:
325
345
  - app/models/iron/first_run.rb
326
346
  - app/models/iron/icon_catalog.rb
327
347
  - app/models/iron/locale.rb
348
+ - app/models/iron/qr_code_link.rb
328
349
  - app/models/iron/reference.rb
329
350
  - app/models/iron/session.rb
330
351
  - app/models/iron/user.rb
331
352
  - app/models/iron/user/role.rb
353
+ - app/models/iron/user/transferable.rb
332
354
  - app/views/active_storage/blobs/_blob.html.erb
333
355
  - app/views/iron/account/exports/index.html.erb
334
356
  - app/views/iron/account/exports/new.html.erb
@@ -400,10 +422,12 @@ files:
400
422
  - app/views/iron/published_pages/show.html.erb
401
423
  - app/views/iron/references/new.turbo_stream.erb
402
424
  - app/views/iron/sessions/new.html.erb
425
+ - app/views/iron/sessions/transfers/show.html.erb
403
426
  - app/views/iron/settings/show.html.erb
404
427
  - app/views/iron/shared/_icon_picker.html.erb
405
428
  - app/views/iron/shared/_select.html.erb
406
- - app/views/iron/users/_form.html.erb
429
+ - app/views/iron/users/_invite.html.erb
430
+ - app/views/iron/users/_transfer.html.erb
407
431
  - app/views/iron/users/_user.html.erb
408
432
  - app/views/iron/users/edit.html.erb
409
433
  - app/views/iron/users/index.html.erb
@@ -1,6 +0,0 @@
1
- class PasswordsMailer < ApplicationMailer
2
- def reset(user)
3
- @user = user
4
- mail subject: "Reset your password", to: user.email_address
5
- end
6
- end
@@ -1,21 +0,0 @@
1
- <%= form_with(model: user, class: "mt-4 max-w-96") do |form| %>
2
- <div class="flex flex-col gap-3">
3
- <div class="field">
4
- <%= form.label :email_address, "Email" %>
5
- <div>
6
- <%= form.text_field :email_address %>
7
- <%= form.error_for :email_address %>
8
- </div>
9
- </div>
10
-
11
- <div class="field">
12
- <%= form.label :role, "User role" %>
13
- <div>
14
- <%= form.collection_select :role, Iron::User.roles.keys, :to_s, :titleize %>
15
- <%= form.error_for :role %>
16
- </div>
17
- </div>
18
- </div>
19
-
20
- <%= form.submit class: "button-primary mt-4" %>
21
- <% end %>