empowered.house ↗

security/two-factor.ex

Two-factor

A one-time code typed into two groups of three boxes, with the lock turning to verified when the code is complete.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.security_two_factor animated />
</div>
default
isometric
four digits entered
nothing entered · custom copy · hover
light surface
five digits · fade out · still

Props

Prop Type Default What it does
title string "Two-step verification"
hint string "Enter the code from your authenticator" Up to 44 characters.
code string "481 209" The six digits. Anything that is not a digit is ignored.
entered integer 6 How many digits are typed in, 0 to 6. At 6 the lock turns to verified.
outcome any nil The footer line. Left as `nil`, it is written from the number of digits entered.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/security/two-factor.ex
two-factor.ex
defmodule EHVisuals.Security.TwoFactor do
  @moduledoc """
  A one-time code typed into two groups of three boxes. The lock turns to
  verified when the last digit is in.
  """

  use EHVisuals.Composition,
    name: "security/two-factor",
    title: "Two-factor",
    description:
      "A one-time code typed into two groups of three boxes, with the lock turning to verified when the code is complete."

  attr(:title, :string, default: "Two-step verification")

  attr(:hint, :string,
    default: "Enter the code from your authenticator",
    doc: "Up to 44 characters."
  )

  attr(:code, :string,
    default: "481 209",
    doc: "The six digits. Anything that is not a digit is ignored."
  )

  attr(:entered, :integer,
    default: 6,
    doc: "How many digits are typed in, 0 to 6. At 6 the lock turns to verified."
  )

  attr(:outcome, :any,
    default: nil,
    doc: "The footer line. Left as `nil`, it is written from the number of digits entered."
  )

  attr(:label, :string, default: "A six digit one-time code, fully entered and verified")

  frame_attrs()

  @spec security_two_factor(map()) :: Phoenix.LiveView.Rendered.t()
  def security_two_factor(assigns) do
    entered = assigns.entered |> max(0) |> min(6)

    digits =
      assigns.code
      |> String.replace(~r/\D/, "")
      |> String.pad_trailing(6, "0")
      |> String.graphemes()
      |> Enum.take(6)

    boxes =
      for {digit, index} <- Enum.with_index(digits) do
        %{
          index: index,
          digit: digit,
          x: if(index < 3, do: 29 + index * 46, else: 181 + (index - 3) * 46),
          state:
            cond do
              index < entered -> "filled"
              index == entered -> "current"
              true -> "empty"
            end
        }
      end

    assigns =
      assigns
      |> assign(:boxes, boxes)
      |> assign(:verified, entered == 6)
      |> assign(:footer, assigns.outcome || outcome(entered))

    ~H"""
    <.frame name="security/two-factor" label={@label} {frame_opts(assigns)}>
      <svg viewBox="0 0 340 250">
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="339" height="249" rx="12" />
          <g class="ehv-a-pop ehv-i-1">
            <circle class="ehv-card-3" cx="170" cy="50" r="23" />
            <path class="ehv-security-two-factor__shackle" d="M163.5 49v-5.5a6.5 6.5 0 0 1 13 0v5.5" />
            <rect class="ehv-fill-2" x="159" y="48" width="22" height="16" rx="4" />
          </g>
          <g :if={@verified} class="ehv-a-pop ehv-i-10">
            <circle class="ehv-fill-lime" cx="170" cy="50" r="23.5" />
            <path
              class="ehv-security-two-factor__shackle ehv-security-two-factor__shackle--open"
              d="M163.5 49v-8.5a6.5 6.5 0 0 1 13 0v2"
            />
            <rect class="ehv-security-two-factor__body" x="159" y="48" width="22" height="16" rx="4" />
            <path
              class="ehv-security-two-factor__check ehv-round ehv-a-draw ehv-i-12"
              d="M165 56.2l3.4 3.4l6.6 -7.2"
              pathLength="1"
            />
          </g>
          <g class="ehv-a-fade ehv-i-2">
            <text class="ehv-t-strong" x="170" y="98" font-size="14" text-anchor="middle">
              {@title}
            </text>
            <text class="ehv-t-3" x="170" y="116" font-size="11.5" text-anchor="middle">{@hint}</text>
          </g>
          <line class="ehv-line ehv-a-fade ehv-i-3" x1="165" x2="175" y1="156.5" y2="156.5" />
          <g :for={box <- @boxes} class="ehv-security-two-factor__slot">
            <g class={"ehv-a-rise ehv-i-#{box.index + 2}"}>
              <rect
                class={["ehv-security-two-factor__box", box_class(box.state)]}
                x={box.x + 0.5}
                y="132.5"
                width="38"
                height="48"
                rx="8"
              />
            </g>
            <g :if={box.state == "filled"} class={"ehv-a-pop ehv-i-#{box.index + 4}"}>
              <text
                class="ehv-code ehv-t-strong"
                x={box.x + 19.5}
                y="163.5"
                font-size="19"
                text-anchor="middle"
              >
                {box.digit}
              </text>
            </g>
            <g :if={box.state == "current"} class={"ehv-a-fade ehv-i-#{box.index + 4}"}>
              <line
                class="ehv-security-two-factor__caret ehv-a-pulse"
                x1={box.x + 19.5}
                x2={box.x + 19.5}
                y1="146"
                y2="167"
              />
            </g>
            <circle
              :if={box.state == "empty"}
              class={"ehv-fill-4 ehv-a-fade ehv-i-#{box.index + 3}"}
              cx={box.x + 19.5}
              cy="156.5"
              r="2"
            />
          </g>
          <g class="ehv-a-rise ehv-i-11">
            <rect
              class={"ehv-security-two-factor__outcome--#{if @verified, do: "go", else: "wait"}"}
              x="20"
              y="200"
              width="300"
              height="30"
              rx="6"
            />
            <text
              class={[
                "ehv-mono",
                "ehv-security-two-factor__outcome-text--#{if @verified, do: "go", else: "wait"}"
              ]}
              x="170"
              y="218.5"
              font-size="9"
              text-anchor="middle"
            >
              {String.upcase(@footer)}
            </text>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  defp box_class("filled"), do: "ehv-card-2"
  defp box_class("current"), do: "ehv-card-lime"
  defp box_class(_state), do: "ehv-well"

  defp outcome(6), do: "Verified · signed in"
  defp outcome(0), do: "Waiting for the code"
  defp outcome(5), do: "1 digit to go"
  defp outcome(entered), do: "#{6 - entered} digits to go"
end
assets/css/security/two-factor.css
two-factor.css
/* security/two-factor */
.ehv-security-two-factor { --ehv-max-w: 370px; --ehv-step: 110ms; }

.ehv-security-two-factor__shackle { fill: none; stroke: var(--ehv-ink-2); stroke-width: 2.5; stroke-linecap: round; }
.ehv-security-two-factor__shackle--open { stroke: var(--ehv-on-lime); }
.ehv-security-two-factor__body { fill: var(--ehv-on-lime); }
.ehv-security-two-factor__check { fill: none; stroke: var(--ehv-lime-fill); stroke-width: 2; }

.ehv-security-two-factor__caret { fill: none; stroke: var(--ehv-lime); stroke-width: 1.5; stroke-linecap: round; }
.ehv--animated .ehv-security-two-factor__caret { animation-duration: 1.1s; }

.ehv-security-two-factor__outcome--go { fill: var(--ehv-lime-fill); }
.ehv-security-two-factor__outcome--wait { fill: var(--ehv-canvas); stroke: var(--ehv-border-soft); stroke-width: 1; }

.ehv svg .ehv-mono.ehv-security-two-factor__outcome-text--go { fill: var(--ehv-on-lime); }
.ehv svg .ehv-mono.ehv-security-two-factor__outcome-text--wait { fill: var(--ehv-ink-2); }

.ehv--hover .ehv-security-two-factor__box { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-security-two-factor__slot:hover .ehv-security-two-factor__box { stroke: var(--ehv-lime); }