diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css
index 8519318..ff1c848 100644
--- a/assets/css/admin/components.css
+++ b/assets/css/admin/components.css
@@ -1208,6 +1208,105 @@
overflow-x: auto;
}
+.admin-info-box .admin-btn-primary {
+ color: var(--t-text-inverse);
+}
+
+/* ── TOTP / 2FA ── */
+
+.admin-totp-setup {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ align-items: center;
+ text-align: center;
+}
+
+.admin-totp-qr {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.admin-qr-code {
+ padding: 1rem;
+ background: white;
+ border-radius: 0.5rem;
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
+}
+
+.admin-qr-code svg {
+ display: block;
+ width: 180px;
+ height: 180px;
+}
+
+.admin-totp-divider {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ width: 100%;
+ color: var(--admin-text-muted);
+ font-size: 0.8125rem;
+
+ &::before,
+ &::after {
+ content: "";
+ flex: 1;
+ height: 1px;
+ background: var(--admin-border);
+ }
+}
+
+.admin-totp-copy {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+}
+
+.admin-copy-field {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ background: var(--admin-bg-secondary);
+ border-radius: 0.375rem;
+ max-width: 100%;
+}
+
+.admin-copy-field code {
+ font-size: 0.8125rem;
+ word-break: break-all;
+}
+
+.admin-backup-codes {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.5rem;
+ margin-top: 1rem;
+}
+
+.admin-backup-code {
+ padding: 0.375rem 0.75rem;
+ background: white;
+ border-radius: 0.25rem;
+ font-family: ui-monospace, monospace;
+ font-size: 0.875rem;
+ text-align: center;
+ letter-spacing: 0.05em;
+}
+
+.admin-dl-compact {
+ font-size: 0.8125rem;
+}
+
+.admin-dl-compact .admin-dl-row {
+ padding-block: 0.25rem;
+}
+
/* ── Link variants ── */
.admin-link-danger {
diff --git a/assets/css/admin/icons.css b/assets/css/admin/icons.css
index a588df3..5a24850 100644
--- a/assets/css/admin/icons.css
+++ b/assets/css/admin/icons.css
@@ -242,6 +242,18 @@
height: 1.5rem;
}
+.hero-check {
+ --hero-check: url('data:image/svg+xml;utf8,');
+ -webkit-mask: var(--hero-check);
+ mask: var(--hero-check);
+ mask-repeat: no-repeat;
+ background-color: currentColor;
+ vertical-align: middle;
+ display: inline-block;
+ width: 1.5rem;
+ height: 1.5rem;
+}
+
.hero-check-badge {
--hero-check-badge: url('data:image/svg+xml;utf8,');
-webkit-mask: var(--hero-check-badge);
@@ -374,6 +386,18 @@
height: 1.25rem;
}
+.hero-clipboard {
+ --hero-clipboard: url('data:image/svg+xml;utf8,');
+ -webkit-mask: var(--hero-clipboard);
+ mask: var(--hero-clipboard);
+ mask-repeat: no-repeat;
+ background-color: currentColor;
+ vertical-align: middle;
+ display: inline-block;
+ width: 1.5rem;
+ height: 1.5rem;
+}
+
.hero-clipboard-document {
--hero-clipboard-document: url('data:image/svg+xml;utf8,');
-webkit-mask: var(--hero-clipboard-document);
@@ -542,18 +566,6 @@
height: 1.25rem;
}
-.hero-document-plus {
- --hero-document-plus: url('data:image/svg+xml;utf8,');
- -webkit-mask: var(--hero-document-plus);
- mask: var(--hero-document-plus);
- mask-repeat: no-repeat;
- background-color: currentColor;
- vertical-align: middle;
- display: inline-block;
- width: 1.5rem;
- height: 1.5rem;
-}
-
.hero-document-text {
--hero-document-text: url('data:image/svg+xml;utf8,');
-webkit-mask: var(--hero-document-text);
@@ -1010,6 +1022,18 @@
height: 1.5rem;
}
+.hero-shield-check-mini {
+ --hero-shield-check-mini: url('data:image/svg+xml;utf8,');
+ -webkit-mask: var(--hero-shield-check-mini);
+ mask: var(--hero-shield-check-mini);
+ mask-repeat: no-repeat;
+ background-color: currentColor;
+ vertical-align: middle;
+ display: inline-block;
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
.hero-shopping-bag {
--hero-shopping-bag: url('data:image/svg+xml;utf8,');
-webkit-mask: var(--hero-shopping-bag);
@@ -1034,18 +1058,6 @@
height: 1.5rem;
}
-.hero-signal {
- --hero-signal: url('data:image/svg+xml;utf8,');
- -webkit-mask: var(--hero-signal);
- mask: var(--hero-signal);
- mask-repeat: no-repeat;
- background-color: currentColor;
- vertical-align: middle;
- display: inline-block;
- width: 1.5rem;
- height: 1.5rem;
-}
-
.hero-squares-2x2 {
--hero-squares-2x2: url('data:image/svg+xml;utf8,');
-webkit-mask: var(--hero-squares-2x2);
@@ -1166,6 +1178,18 @@
height: 1.5rem;
}
+.hero-user-circle {
+ --hero-user-circle: url('data:image/svg+xml;utf8,');
+ -webkit-mask: var(--hero-user-circle);
+ mask: var(--hero-user-circle);
+ mask-repeat: no-repeat;
+ background-color: currentColor;
+ vertical-align: middle;
+ display: inline-block;
+ width: 1.5rem;
+ height: 1.5rem;
+}
+
.hero-users {
--hero-users: url('data:image/svg+xml;utf8,');
-webkit-mask: var(--hero-users);
@@ -1190,18 +1214,6 @@
height: 1.5rem;
}
-.hero-x-circle {
- --hero-x-circle: url('data:image/svg+xml;utf8,');
- -webkit-mask: var(--hero-x-circle);
- mask: var(--hero-x-circle);
- mask-repeat: no-repeat;
- background-color: currentColor;
- vertical-align: middle;
- display: inline-block;
- width: 1.5rem;
- height: 1.5rem;
-}
-
.hero-x-circle-mini {
--hero-x-circle-mini: url('data:image/svg+xml;utf8,');
-webkit-mask: var(--hero-x-circle-mini);
diff --git a/assets/js/app.js b/assets/js/app.js
index e964183..9c58b5d 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -735,6 +735,46 @@ const EditorSheet = {
}
}
+// Clipboard: copy text from a target element to clipboard
+const Clipboard = {
+ mounted() {
+ this.el.addEventListener("click", () => {
+ const targetId = this.el.dataset.copyTarget
+ const target = document.getElementById(targetId)
+ if (!target) return
+
+ const text = target.textContent.trim()
+ this._copyText(text).then(() => {
+ const span = this.el.querySelector("span")
+ if (span) {
+ const original = span.textContent
+ span.textContent = "Copied!"
+ setTimeout(() => { span.textContent = original }, 1500)
+ }
+ })
+ })
+ },
+
+ _copyText(text) {
+ // Modern API (requires HTTPS or localhost)
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ return navigator.clipboard.writeText(text)
+ }
+ // Fallback for HTTP contexts
+ return new Promise((resolve) => {
+ const textarea = document.createElement("textarea")
+ textarea.value = text
+ textarea.style.position = "fixed"
+ textarea.style.opacity = "0"
+ document.body.appendChild(textarea)
+ textarea.select()
+ document.execCommand("copy")
+ document.body.removeChild(textarea)
+ resolve()
+ })
+ }
+}
+
// DirtyGuard + Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors
const EditorKeyboard = {
mounted() {
@@ -788,7 +828,7 @@ const EditorKeyboard = {
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
- hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, MobileNavDrawer, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard, EditorKeyboard, EditorSheet},
+ hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, MobileNavDrawer, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, Clipboard, DirtyGuard, EditorKeyboard, EditorSheet},
})
// Show progress bar on live navigation and form submits
diff --git a/lib/berrypod/accounts.ex b/lib/berrypod/accounts.ex
index 4f22d20..6448529 100644
--- a/lib/berrypod/accounts.ex
+++ b/lib/berrypod/accounts.ex
@@ -5,9 +5,13 @@ defmodule Berrypod.Accounts do
import Ecto.Query, warn: false
alias Berrypod.Repo
+ alias Berrypod.Vault
alias Berrypod.Accounts.{User, UserToken, UserNotifier}
+ @totp_issuer "Berrypod"
+ @backup_code_count 8
+
## Database getters
@doc """
@@ -342,4 +346,147 @@ defmodule Berrypod.Accounts do
end
end)
end
+
+ ## TOTP 2FA
+
+ @doc """
+ Generates a new TOTP secret for setup.
+
+ Returns `{secret, otpauth_uri}` where secret is the raw binary
+ and otpauth_uri can be encoded as a QR code.
+ """
+ def generate_totp_secret(user) do
+ secret = NimbleTOTP.secret()
+ uri = totp_uri(user, secret)
+ {secret, uri}
+ end
+
+ @doc """
+ Generates the otpauth URI for a given user and secret.
+
+ Used to regenerate the URI when restoring TOTP setup state from session.
+ """
+ def totp_uri(user, secret) do
+ NimbleTOTP.otpauth_uri("#{@totp_issuer}:#{user.email}", secret, issuer: @totp_issuer)
+ end
+
+ @doc """
+ Enables TOTP for a user after verifying the code.
+
+ The secret should be the raw binary from `generate_totp_secret/1`.
+ Returns `{:ok, user, backup_codes}` or `{:error, reason}`.
+ """
+ def enable_totp(user, secret, code) do
+ if valid_totp?(secret, code) do
+ backup_codes = generate_backup_codes()
+ hashed_codes = Enum.map(backup_codes, &Bcrypt.hash_pwd_salt/1)
+
+ {:ok, encrypted_secret} = Vault.encrypt(Base.encode32(secret))
+ {:ok, encrypted_codes} = Vault.encrypt(Jason.encode!(hashed_codes))
+
+ changeset =
+ user
+ |> Ecto.Changeset.change(%{
+ totp_secret_encrypted: encrypted_secret,
+ totp_backup_codes_encrypted: encrypted_codes,
+ totp_enabled_at: DateTime.utc_now(:second)
+ })
+
+ case Repo.update(changeset) do
+ {:ok, user} -> {:ok, user, backup_codes}
+ {:error, changeset} -> {:error, changeset}
+ end
+ else
+ {:error, :invalid_code}
+ end
+ end
+
+ @doc """
+ Disables TOTP for a user.
+ """
+ def disable_totp(user) do
+ changeset =
+ user
+ |> Ecto.Changeset.change(%{
+ totp_secret_encrypted: nil,
+ totp_backup_codes_encrypted: nil,
+ totp_enabled_at: nil
+ })
+
+ Repo.update(changeset)
+ end
+
+ @doc """
+ Verifies a TOTP code for a user.
+
+ Also accepts backup codes, which are single-use.
+ """
+ def verify_totp(user, code) do
+ cond do
+ valid_user_totp?(user, code) ->
+ :ok
+
+ valid_backup_code?(user, code) ->
+ consume_backup_code(user, code)
+ :ok
+
+ true ->
+ :error
+ end
+ end
+
+ @doc """
+ Returns true if the user has TOTP enabled.
+ """
+ def totp_enabled?(user), do: User.totp_enabled?(user)
+
+ defp valid_totp?(secret, code) when is_binary(secret) and is_binary(code) do
+ NimbleTOTP.valid?(secret, code)
+ end
+
+ defp valid_totp?(_, _), do: false
+
+ defp valid_user_totp?(user, code) do
+ with encrypted when not is_nil(encrypted) <- user.totp_secret_encrypted,
+ {:ok, encoded} <- Vault.decrypt(encrypted),
+ {:ok, secret} <- Base.decode32(encoded) do
+ valid_totp?(secret, code)
+ else
+ _ -> false
+ end
+ end
+
+ defp valid_backup_code?(user, code) do
+ hashed_codes = get_hashed_backup_codes(user)
+ Enum.any?(hashed_codes, &Bcrypt.verify_pass(code, &1))
+ end
+
+ defp consume_backup_code(user, code) do
+ hashed_codes = get_hashed_backup_codes(user)
+ remaining = Enum.reject(hashed_codes, &Bcrypt.verify_pass(code, &1))
+
+ {:ok, encrypted} = Vault.encrypt(Jason.encode!(remaining))
+
+ user
+ |> Ecto.Changeset.change(%{totp_backup_codes_encrypted: encrypted})
+ |> Repo.update()
+ end
+
+ defp get_hashed_backup_codes(user) do
+ with encrypted when not is_nil(encrypted) <- user.totp_backup_codes_encrypted,
+ {:ok, json} <- Vault.decrypt(encrypted),
+ {:ok, codes} <- Jason.decode(json) do
+ codes
+ else
+ _ -> []
+ end
+ end
+
+ defp generate_backup_codes do
+ for _ <- 1..@backup_code_count do
+ :crypto.strong_rand_bytes(5)
+ |> Base.encode32(padding: false)
+ |> String.downcase()
+ end
+ end
end
diff --git a/lib/berrypod/accounts/user.ex b/lib/berrypod/accounts/user.ex
index 67ffb90..aac09e7 100644
--- a/lib/berrypod/accounts/user.ex
+++ b/lib/berrypod/accounts/user.ex
@@ -11,9 +11,20 @@ defmodule Berrypod.Accounts.User do
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
+ # TOTP 2FA fields (encrypted at rest)
+ field :totp_secret_encrypted, :binary, redact: true
+ field :totp_secret, :string, virtual: true, redact: true
+ field :totp_enabled_at, :utc_datetime
+ field :totp_backup_codes_encrypted, :binary, redact: true
+ field :totp_backup_codes, {:array, :string}, virtual: true, redact: true
+
timestamps(type: :utc_datetime)
end
+ @doc "Returns true if the user has 2FA enabled."
+ def totp_enabled?(%__MODULE__{totp_enabled_at: nil}), do: false
+ def totp_enabled?(%__MODULE__{totp_enabled_at: _}), do: true
+
@doc """
A user changeset for registering or changing the email.
diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex
index 2f2f266..b6db09e 100644
--- a/lib/berrypod_web/components/layouts/admin.html.heex
+++ b/lib/berrypod_web/components/layouts/admin.html.heex
@@ -34,6 +34,19 @@