security/lock.ex
Lock
A padlock on one resource, with the roles that may open it as chips, for access control.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.security_lock animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
caption |
string | "Access control" |
|
resource |
string | "Payment runs" |
What is locked, up to 28 characters. |
roles |
list | ["Finance lead", "Controller", "CFO"] |
The roles that may open it, up to eight names of up to 16 characters. |
denied |
list | ["Sales", "Support", "Guests"] |
Roles without access, drawn dashed. Up to six names. An empty list leaves the row out. |
opened_by |
integer | -1 |
Index of the role that has it open right now: the shackle lifts and that chip turns lime. A negative index keeps it locked. |
note |
any | nil |
The line under the resource. Left as `nil`, it is written from the roles. |
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/lock.ex
defmodule EHVisuals.Security.Lock do
@moduledoc """
A padlock on one resource, with the roles that may open it as chips and the
ones that may not underneath.
"""
use EHVisuals.Composition,
name: "security/lock",
title: "Lock",
description:
"A padlock on one resource, with the roles that may open it as chips, for access control."
attr(:caption, :string, default: "Access control")
attr(:resource, :string, default: "Payment runs", doc: "What is locked, up to 28 characters.")
attr(:roles, :list,
default: ["Finance lead", "Controller", "CFO"],
doc: "The roles that may open it, up to eight names of up to 16 characters."
)
attr(:denied, :list,
default: ["Sales", "Support", "Guests"],
doc: "Roles without access, drawn dashed. Up to six names. An empty list leaves the row out."
)
attr(:opened_by, :integer,
default: -1,
doc:
"Index of the role that has it open right now: the shackle lifts and that chip turns lime. A negative index keeps it locked."
)
attr(:note, :any,
default: nil,
doc: "The line under the resource. Left as `nil`, it is written from the roles."
)
attr(:label, :string,
default: "A padlock on payment runs with the three roles that may open it"
)
frame_attrs()
@spec security_lock(map()) :: Phoenix.LiveView.Rendered.t()
def security_lock(assigns) do
roles = Enum.take(assigns.roles, 8)
opener = if assigns.opened_by >= 0, do: Enum.at(roles, assigns.opened_by)
open = opener != nil
{allowed, y} = flow(roles, 242)
allowed = Enum.map(allowed, &Map.put(&1, :active, open and &1.index == assigns.opened_by))
{refused, bottom} =
case Enum.take(assigns.denied, 6) do
[] -> {[], y}
names -> flow(names, y + 32)
end
status = if open, do: "Open", else: "Locked"
assigns =
assigns
|> assign(:open, open)
|> assign(:allowed, allowed)
|> assign(:refused, refused)
|> assign(:refused_y, y + 10)
|> assign(:height, bottom + 18)
|> assign(:status, status)
|> assign(:pill_w, Float.round(18 + String.length(status) * 5.8, 1))
|> assign(:line, assigns.note || note(opener, length(roles)))
~H"""
<.frame name="security/lock" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 340 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="339" height={@height - 1} rx="12" />
<text class="ehv-mono ehv-a-fade ehv-i-1" x="20" y="31" font-size="9">
{String.upcase(@caption)}
</text>
<g class="ehv-a-fade ehv-i-6">
<rect
class={if @open, do: "ehv-fill-lime-soft", else: "ehv-security-lock__pill"}
x={320 - @pill_w}
y="17"
width={@pill_w}
height="20"
rx="6"
/>
<text
class={["ehv-mono", if(@open, do: "ehv-t-lime", else: "ehv-t-2")]}
x={320 - @pill_w / 2}
y="30.2"
font-size="8.5"
text-anchor="middle"
>
{String.upcase(@status)}
</text>
</g>
<g class="ehv-a-pop ehv-i-1">
<circle class="ehv-line-faint" cx="170" cy="108" r="62" />
</g>
<g class="ehv-a-pop ehv-i-2">
<circle class="ehv-line-soft" cx="170" cy="108" r="43" />
</g>
<g class="ehv-a-pop ehv-i-2">
<g class={"ehv-security-lock__latch ehv-security-lock__latch--#{if @open, do: "open", else: "shut"}"}>
<path
class="ehv-security-lock__shackle"
d={
if @open,
do: "M155 98v-22a15 15 0 0 1 30 0v5",
else: "M155 98v-13a15 15 0 0 1 30 0v13"
}
/>
</g>
<rect class="ehv-security-lock__body" x="142" y="96" width="56" height="44" rx="10" />
<circle class="ehv-fill-lime" cx="170" cy="113" r="5" />
<rect class="ehv-fill-lime" x="168" y="115" width="4" height="13" rx="2" />
</g>
<g class="ehv-a-fade ehv-i-3">
<text class="ehv-t-strong" x="170" y="190" font-size="15" text-anchor="middle">
{@resource}
</text>
<text class="ehv-t-3" x="170" y="208" font-size="11.5" text-anchor="middle">{@line}</text>
</g>
<line class="ehv-line-faint" x1="20" x2="320" y1="222.5" y2="222.5" />
<text class="ehv-mono ehv-a-fade ehv-i-4" x="20" y="236" font-size="7.5">CAN OPEN</text>
<g :for={chip <- @allowed} class={"ehv-a-pop ehv-i-#{min(chip.index + 4, 14)}"}>
<rect
class={[
"ehv-security-lock__chip",
if(chip.active, do: "ehv-card-lime", else: "ehv-card-2")
]}
x={chip.x}
y={chip.y}
width={chip.w}
height="24"
rx="7"
/>
<path
class={[
"ehv-round",
if(chip.active, do: "ehv-line-lime", else: "ehv-line-ink")
]}
d={"M#{chip.x + 9} #{chip.y + 12.2}l2.6 2.6l4.6 -5.2"}
/>
<text
class={[chip.active && "ehv-t-lime"]}
x={chip.x + 22}
y={chip.y + 16}
font-size="11"
>
{chip.name}
</text>
</g>
<text
:if={@refused != []}
class="ehv-mono ehv-t-4 ehv-a-fade ehv-i-8"
x="20"
y={@refused_y + 16}
font-size="7.5"
>
NO ACCESS
</text>
<g :for={chip <- @refused} class={"ehv-a-fade ehv-i-#{min(chip.index + 9, 16)}"}>
<rect
class="ehv-security-lock__chip ehv-security-lock__chip--off"
x={chip.x}
y={chip.y}
width={chip.w}
height="24"
rx="7"
/>
<path
class="ehv-security-lock__cross ehv-round"
d={"M#{chip.x + 9.5} #{chip.y + 9}l6 6m0 -6l-6 6"}
/>
<text class="ehv-t-3" x={chip.x + 22} y={chip.y + 16} font-size="11">{chip.name}</text>
</g>
</g>
</svg>
</.frame>
"""
end
# Lays chips out left to right from `y`, wrapping at the card's inner edge.
# Returns the chips and the bottom of the last row.
defp flow(names, y) do
{chips, {_x, row_y}} =
names
|> Enum.with_index()
|> Enum.map_reduce({20, y}, fn {name, index}, {x, row_y} ->
w = Float.round(31 + String.length(name) * 5.8, 1)
{x, row_y} = if x + w > 320 and x > 20, do: {20, row_y + 30}, else: {x, row_y}
{%{name: name, index: index, x: x, y: row_y, w: w}, {x + w + 6, row_y}}
end)
{chips, row_y + 24}
end
defp note(nil, 1), do: "1 role can open this"
defp note(nil, count), do: "#{count} roles can open this"
defp note(opener, _count), do: "Opened by #{opener}"
end
assets/css/security/lock.css
/* security/lock */
.ehv-security-lock { --ehv-max-w: 370px; }
.ehv-security-lock__body { fill: var(--ehv-surface-3); stroke: var(--ehv-border-strong); stroke-width: 1; }
.ehv-security-lock__shackle { fill: none; stroke: var(--ehv-ink-2); stroke-width: 5; stroke-linecap: round; }
.ehv-security-lock__pill { fill: var(--ehv-surface-3); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-security-lock__chip--off { fill: none; stroke: var(--ehv-border-strong); stroke-width: 1; stroke-dasharray: 2 3; }
.ehv-security-lock__cross { fill: none; stroke: var(--ehv-ink-4); stroke-width: 1.25; }
/* The shackle clicks shut, or lifts when a role has the lock open. */
@keyframes ehv-security-lock-shut { from { transform: translateY(-8px); } }
@keyframes ehv-security-lock-open { from { transform: translateY(9px); } }
.ehv--animated .ehv-security-lock__latch {
animation-duration: 500ms;
animation-timing-function: var(--ehv-ease);
animation-fill-mode: backwards;
animation-delay: calc(var(--ehv-delay, 0ms) + 900ms);
}
.ehv--animated .ehv-security-lock__latch--shut { animation-name: ehv-security-lock-shut; }
.ehv--animated .ehv-security-lock__latch--open { animation-name: ehv-security-lock-open; }
.ehv--hover .ehv-security-lock__chip { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-security-lock__chip:hover { stroke: var(--ehv-lime); }