From 32cc425458e8d3b7f0ad9c087a67ef1d31da26f7 Mon Sep 17 00:00:00 2001 From: jamey Date: Sun, 8 Mar 2026 18:42:29 +0000 Subject: [PATCH] separate account settings from shop settings - Create dedicated /admin/account page for user account management - Move email, password, and 2FA settings from /admin/settings - Add Account link to top of admin sidebar navigation - Add TOTP-based two-factor authentication with NimbleTOTP - Add TOTP verification LiveView for login flow - Add AccountController for TOTP session management - Remove Advanced section from settings (duplicated in dev tools) - Remove user email from sidebar footer (replaced by Account link) Co-Authored-By: Claude Opus 4.5 --- assets/css/admin/components.css | 99 ++++ assets/css/admin/icons.css | 84 ++-- assets/js/app.js | 42 +- lib/berrypod/accounts.ex | 147 ++++++ lib/berrypod/accounts/user.ex | 11 + .../components/layouts/admin.html.heex | 16 +- .../controllers/account_controller.ex | 63 +++ .../controllers/user_session_controller.ex | 65 ++- lib/berrypod_web/live/admin/account.ex | 466 ++++++++++++++++++ lib/berrypod_web/live/admin/settings.ex | 159 +----- lib/berrypod_web/live/auth/login.ex | 8 +- .../live/auth/totp_verification.ex | 90 ++++ lib/berrypod_web/router.ex | 8 + mix.exs | 4 +- mix.lock | 2 + .../20260308085927_add_totp_to_users.exs | 14 + test/berrypod/accounts_test.exs | 100 ++++ test/berrypod/rate_limit_test.exs | 101 ++++ test/berrypod_web/live/admin/account_test.exs | 123 +++++ test/berrypod_web/live/admin/layout_test.exs | 6 - .../berrypod_web/live/admin/settings_test.exs | 96 +--- 21 files changed, 1396 insertions(+), 308 deletions(-) create mode 100644 lib/berrypod_web/controllers/account_controller.ex create mode 100644 lib/berrypod_web/live/admin/account.ex create mode 100644 lib/berrypod_web/live/auth/totp_verification.ex create mode 100644 priv/repo/migrations/20260308085927_add_totp_to_users.exs create mode 100644 test/berrypod/rate_limit_test.exs create mode 100644 test/berrypod_web/live/admin/account_test.exs 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,%20%20'); + -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,%20%20'); -webkit-mask: var(--hero-check-badge); @@ -374,6 +386,18 @@ height: 1.25rem; } +.hero-clipboard { + --hero-clipboard: url('data:image/svg+xml;utf8,%20%20'); + -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,%20%20'); -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,%20%20'); - -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,%20%20'); -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,%20%20'); + -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,%20%20'); -webkit-mask: var(--hero-shopping-bag); @@ -1034,18 +1058,6 @@ height: 1.5rem; } -.hero-signal { - --hero-signal: url('data:image/svg+xml;utf8,%20%20'); - -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,%20%20'); -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,%20%20'); + -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,%20%20'); -webkit-mask: var(--hero-users); @@ -1190,18 +1214,6 @@ height: 1.5rem; } -.hero-x-circle { - --hero-x-circle: url('data:image/svg+xml;utf8,%20%20'); - -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,%20%20'); -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 @@