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.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.security_two_factor animated />
</div>
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
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
/* 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); }