mirror of
https://github.com/plausible/analytics.git
synced 2024-12-26 11:02:52 +03:00
da0fa6c355
* Add 2FA actions to `AuthController` * Hook up new `AuthController` actions to router * Add `qr_code` to project dependencies * Implement generic `qr_code` component rendering SVG QR code from text * Implement enabled and disabled 2FA setting state in user settings view * Implement view for initiating 2FA setup * Implement view for verifying 2FA setup * Implement view for rendering generated 2FA recovery codes * Implement view for verifying 2FA code * Implement view for verifying 2FA recovery code * Improve `input_with_clipboard` component * Improve view for initiating 2FA setup * Improve verify 2FA setup view * Implement `verify_2fa_input` component * Improve view for verifying 2FA setup * Improve view rendering generated 2FA recovery codes * Use `verify_2fa_input` component in verify 2FA view * Do not render PA contact on self-hosted instances * Improve flash message phrasing on generated recovery codes * Add byline with a warning to disable 2FA modal * Extract modal to component and move 2FA components to dedicated module * First pass on loading state for "generate new codes" * Adjust modal button logic * Fix button in verify_2fa_input component * Use button component in activate view * Implement wait states for recovery code related actions properly * Apply rate limiting to 2FA verification * Log failed 2FA code input attempts * Add ability to trust device and skip 2FA for 30 days * Improve styling in dark mode * Fix waiting state under Chrome and Safari * Delete trust cookie when disabling 2FA * Put 2FA behind a feature flag * Extract 2FA cookie deletion * ff fixup * Improve session management during 2FA login * Extract part of 2FA controller logic to a separate module and clean up a bit * Clear 2FA user session when rate limit hit * Add id to form in verify 2FA setup view * Add controller tests for 2FA actions and login action * Update CHANGELOG.md * Use `full_build?()` instead of `@is_selfhost` removed after rebase * Update `Auth.TOTP` moduledoc * Add TOTP token management and make `TOTP.enable` more test-friendly * Use TOTP token for device trust feature * Use zero-deps `eqrcode` instead of deps-heavy `qr_code` * Improve flash messages copy Co-authored-by: hq1 <hq@mtod.org> * Make one more copy improvement Co-authored-by: hq1 <hq@mtod.org> * Fix copy in remaining spots * Change redirect after login to accept URLs from #3560 (h/t @aerosol) * Add tests checking handling login_dest on login and 2FA verification * Fix regression in email activation form submit button behavior * Rename `PlausibleWeb.TwoFactor` -> `PlausibleWeb.TwoFactor.Session` * Move `qr_code` component under `Components.TwoFactor` * Set domain and secure options for new cookies --------- Co-authored-by: hq1 <hq@mtod.org>
156 lines
6.2 KiB
Elixir
156 lines
6.2 KiB
Elixir
defmodule PlausibleWeb.Components.TwoFactor do
|
|
@moduledoc """
|
|
Reusable components specific to 2FA
|
|
"""
|
|
use Phoenix.Component
|
|
|
|
attr :text, :string, required: true
|
|
attr :scale, :integer, default: 4
|
|
|
|
def qr_code(assigns) do
|
|
qr_code =
|
|
assigns.text
|
|
|> EQRCode.encode()
|
|
|> EQRCode.svg(%{width: 160})
|
|
|
|
assigns = assign(assigns, :code, qr_code)
|
|
|
|
~H"""
|
|
<%= Phoenix.HTML.raw(@code) %>
|
|
"""
|
|
end
|
|
|
|
attr :id, :string, default: "verify-button"
|
|
attr :form, :any, required: true
|
|
attr :field, :any, required: true
|
|
attr :class, :string, default: ""
|
|
|
|
def verify_2fa_input(assigns) do
|
|
~H"""
|
|
<div class={[@class, "flex justify-center sm:justify-start"]}>
|
|
<%= Phoenix.HTML.Form.text_input(@form, @field,
|
|
autocomplete: "off",
|
|
class:
|
|
"font-mono tracking-[0.5em] w-36 pl-5 font-medium shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block border-gray-300 dark:border-gray-500 dark:text-gray-200 dark:bg-gray-900 rounded-l-md",
|
|
oninput:
|
|
"this.value=this.value.replace(/[^0-9]/g, ''); if (this.value.length >= 6) document.getElementById('verify-button').focus()",
|
|
onclick: "this.select();",
|
|
oninvalid: "document.getElementById('verify-button').disabled = false",
|
|
maxlength: "6",
|
|
placeholder: "••••••",
|
|
value: "",
|
|
required: "required"
|
|
) %>
|
|
<PlausibleWeb.Components.Generic.button
|
|
type="submit"
|
|
id={@id}
|
|
class="rounded-l-none [&>span.label-enabled]:block [&>span.label-disabled]:hidden [&[disabled]>span.label-enabled]:hidden [&[disabled]>span.label-disabled]:block"
|
|
>
|
|
<span class="label-enabled pointer-events-none">
|
|
Verify →
|
|
</span>
|
|
|
|
<span class="label-disabled">
|
|
<PlausibleWeb.Components.Generic.spinner class="inline-block h-5 w-5 mr-2 text-white dark:text-gray-400" />
|
|
Verifying...
|
|
</span>
|
|
</PlausibleWeb.Components.Generic.button>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
attr :id, :string, required: true
|
|
attr :state_param, :string, required: true
|
|
attr :form_data, :any, required: true
|
|
attr :form_target, :string, required: true
|
|
attr :onsubmit, :string, default: nil
|
|
attr :title, :string, required: true
|
|
|
|
slot :icon, required: true
|
|
slot :inner_block, required: true
|
|
slot :buttons, required: true
|
|
|
|
def modal(assigns) do
|
|
~H"""
|
|
<div
|
|
id={@id}
|
|
x-cloak
|
|
x-show={@state_param}
|
|
x-on:keyup.escape.window={"#{@state_param} = false"}
|
|
class="fixed z-10 inset-0 overflow-y-auto"
|
|
aria-labelledby="modal-title"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
<div
|
|
x-show={@state_param}
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="fixed inset-0 bg-gray-500 dark:bg-gray-800 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
|
|
aria-hidden="true"
|
|
x-on:click={"#{@state_param} = false"}
|
|
>
|
|
</div>
|
|
<!-- This element is to trick the browser into centering the modal contents. -->
|
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
|
​
|
|
</span>
|
|
|
|
<div
|
|
x-show={@state_param}
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
|
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
class="inline-block align-bottom bg-white dark:bg-gray-900 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
|
|
>
|
|
<%= Phoenix.HTML.Form.form_for @form_data, @form_target, [onsubmit: @onsubmit], fn f -> %>
|
|
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
<div class="hidden sm:block absolute top-0 right-0 pt-4 pr-4">
|
|
<a
|
|
href="#"
|
|
x-on:click.prevent={"#{@state_param} = false"}
|
|
class="bg-white dark:bg-gray-800 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 focus:outline-none"
|
|
>
|
|
<span class="sr-only">Close</span>
|
|
<Heroicons.x_mark class="h-6 w-6" />
|
|
</a>
|
|
</div>
|
|
<div class="sm:flex sm:items-start">
|
|
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
<%= render_slot(@icon) %>
|
|
</div>
|
|
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left text-gray-900 dark:text-gray-100">
|
|
<h3 class="text-lg leading-6 font-medium" id="modal-title">
|
|
<%= @title %>
|
|
</h3>
|
|
|
|
<%= render_slot(@inner_block, f) %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-gray-850 px-4 py-3 sm:px-9 sm:flex sm:flex-row-reverse">
|
|
<%= render_slot(@buttons) %>
|
|
<button
|
|
type="button"
|
|
class="sm:mr-2 mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
x-on:click={"#{@state_param} = false"}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|