empowered.house ↗

security/lock.ex

Lock

A padlock on one resource, with the roles that may open it as chips, for access control.

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

<div class="h-96 rounded-xl border border-border">
  <.security_lock animated />
</div>
default
isometric
opened by a role · custom copy
hover · no glow · no denied row
light surface
fade out · still

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
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
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); }