separate account settings from shop settings
All checks were successful
deploy / deploy (push) Successful in 3m28s

- 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 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-03-08 18:42:29 +00:00
parent 0c2d4ac406
commit 32cc425458
21 changed files with 1396 additions and 308 deletions

View File

@ -1208,6 +1208,105 @@
overflow-x: auto; 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 ── */ /* ── Link variants ── */
.admin-link-danger { .admin-link-danger {

View File

@ -242,6 +242,18 @@
height: 1.5rem; height: 1.5rem;
} }
.hero-check {
--hero-check: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="m4.5%2012.75%206%206%209-13.5"/></svg>');
-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 {
--hero-check-badge: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9%2012.75%2011.25%2015%2015%209.75M21%2012c0%201.268-.63%202.39-1.593%203.068a3.745%203.745%200%200%201-1.043%203.296%203.745%203.745%200%200%201-3.296%201.043A3.745%203.745%200%200%201%2012%2021c-1.268%200-2.39-.63-3.068-1.593a3.746%203.746%200%200%201-3.296-1.043%203.745%203.745%200%200%201-1.043-3.296A3.745%203.745%200%200%201%203%2012c0-1.268.63-2.39%201.593-3.068a3.745%203.745%200%200%201%201.043-3.296%203.746%203.746%200%200%201%203.296-1.043A3.746%203.746%200%200%201%2012%203c1.268%200%202.39.63%203.068%201.593a3.746%203.746%200%200%201%203.296%201.043%203.746%203.746%200%200%201%201.043%203.296A3.745%203.745%200%200%201%2021%2012Z"/></svg>'); --hero-check-badge: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9%2012.75%2011.25%2015%2015%209.75M21%2012c0%201.268-.63%202.39-1.593%203.068a3.745%203.745%200%200%201-1.043%203.296%203.745%203.745%200%200%201-3.296%201.043A3.745%203.745%200%200%201%2012%2021c-1.268%200-2.39-.63-3.068-1.593a3.746%203.746%200%200%201-3.296-1.043%203.745%203.745%200%200%201-1.043-3.296A3.745%203.745%200%200%201%203%2012c0-1.268.63-2.39%201.593-3.068a3.745%203.745%200%200%201%201.043-3.296%203.746%203.746%200%200%201%203.296-1.043A3.746%203.746%200%200%201%2012%203c1.268%200%202.39.63%203.068%201.593a3.746%203.746%200%200%201%203.296%201.043%203.746%203.746%200%200%201%201.043%203.296A3.745%203.745%200%200%201%2021%2012Z"/></svg>');
-webkit-mask: var(--hero-check-badge); -webkit-mask: var(--hero-check-badge);
@ -374,6 +386,18 @@
height: 1.25rem; height: 1.25rem;
} }
.hero-clipboard {
--hero-clipboard: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M15.666%203.888A2.25%202.25%200%200%200%2013.5%202.25h-3c-1.03%200-1.9.693-2.166%201.638m7.332%200c.055.194.084.4.084.612v0a.75.75%200%200%201-.75.75H9a.75.75%200%200%201-.75-.75v0c0-.212.03-.418.084-.612m7.332%200c.646.049%201.288.11%201.927.184%201.1.128%201.907%201.077%201.907%202.185V19.5a2.25%202.25%200%200%201-2.25%202.25H6.75A2.25%202.25%200%200%201%204.5%2019.5V6.257c0-1.108.806-2.057%201.907-2.185a48.208%2048.208%200%200%201%201.927-.184"/></svg>');
-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 {
--hero-clipboard-document: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M8.25%207.5V6.108c0-1.135.845-2.098%201.976-2.192.373-.03.748-.057%201.123-.08M15.75%2018H18a2.25%202.25%200%200%200%202.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424%2048.424%200%200%200-1.123-.08M15.75%2018.75v-1.875a3.375%203.375%200%200%200-3.375-3.375h-1.5a1.125%201.125%200%200%201-1.125-1.125v-1.5A3.375%203.375%200%200%200%206.375%207.5H5.25m11.9-3.664A2.251%202.251%200%200%200%2015%202.25h-1.5a2.251%202.251%200%200%200-2.15%201.586m5.8%200c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75%207.5H4.875c-.621%200-1.125.504-1.125%201.125v12c0%20.621.504%201.125%201.125%201.125h9.75c.621%200%201.125-.504%201.125-1.125V16.5a9%209%200%200%200-9-9Z"/></svg>'); --hero-clipboard-document: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M8.25%207.5V6.108c0-1.135.845-2.098%201.976-2.192.373-.03.748-.057%201.123-.08M15.75%2018H18a2.25%202.25%200%200%200%202.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424%2048.424%200%200%200-1.123-.08M15.75%2018.75v-1.875a3.375%203.375%200%200%200-3.375-3.375h-1.5a1.125%201.125%200%200%201-1.125-1.125v-1.5A3.375%203.375%200%200%200%206.375%207.5H5.25m11.9-3.664A2.251%202.251%200%200%200%2015%202.25h-1.5a2.251%202.251%200%200%200-2.15%201.586m5.8%200c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75%207.5H4.875c-.621%200-1.125.504-1.125%201.125v12c0%20.621.504%201.125%201.125%201.125h9.75c.621%200%201.125-.504%201.125-1.125V16.5a9%209%200%200%200-9-9Z"/></svg>');
-webkit-mask: var(--hero-clipboard-document); -webkit-mask: var(--hero-clipboard-document);
@ -542,18 +566,6 @@
height: 1.25rem; height: 1.25rem;
} }
.hero-document-plus {
--hero-document-plus: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M19.5%2014.25v-2.625a3.375%203.375%200%200%200-3.375-3.375h-1.5A1.125%201.125%200%200%201%2013.5%207.125v-1.5a3.375%203.375%200%200%200-3.375-3.375H8.25m3.75%209v6m3-3H9m1.5-12H5.625c-.621%200-1.125.504-1.125%201.125v17.25c0%20.621.504%201.125%201.125%201.125h12.75c.621%200%201.125-.504%201.125-1.125V11.25a9%209%200%200%200-9-9Z"/></svg>');
-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 {
--hero-document-text: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M19.5%2014.25v-2.625a3.375%203.375%200%200%200-3.375-3.375h-1.5A1.125%201.125%200%200%201%2013.5%207.125v-1.5a3.375%203.375%200%200%200-3.375-3.375H8.25m0%2012.75h7.5m-7.5%203H12M10.5%202.25H5.625c-.621%200-1.125.504-1.125%201.125v17.25c0%20.621.504%201.125%201.125%201.125h12.75c.621%200%201.125-.504%201.125-1.125V11.25a9%209%200%200%200-9-9Z"/></svg>'); --hero-document-text: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M19.5%2014.25v-2.625a3.375%203.375%200%200%200-3.375-3.375h-1.5A1.125%201.125%200%200%201%2013.5%207.125v-1.5a3.375%203.375%200%200%200-3.375-3.375H8.25m0%2012.75h7.5m-7.5%203H12M10.5%202.25H5.625c-.621%200-1.125.504-1.125%201.125v17.25c0%20.621.504%201.125%201.125%201.125h12.75c.621%200%201.125-.504%201.125-1.125V11.25a9%209%200%200%200-9-9Z"/></svg>');
-webkit-mask: var(--hero-document-text); -webkit-mask: var(--hero-document-text);
@ -1010,6 +1022,18 @@
height: 1.5rem; height: 1.5rem;
} }
.hero-shield-check-mini {
--hero-shield-check-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20fill-rule="evenodd"%20d="M9.661%202.237a.531.531%200%200%201%20.678%200%2011.947%2011.947%200%200%200%207.078%202.749.5.5%200%200%201%20.479.425c.069.52.104%201.05.104%201.59%200%205.162-3.26%209.563-7.834%2011.256a.48.48%200%200%201-.332%200C5.26%2016.564%202%2012.163%202%207c0-.538.035-1.069.104-1.589a.5.5%200%200%201%20.48-.425%2011.947%2011.947%200%200%200%207.077-2.75Zm4.196%205.954a.75.75%200%200%200-1.214-.882l-3.483%204.79-1.88-1.88a.75.75%200%201%200-1.06%201.061l2.5%202.5a.75.75%200%200%200%201.137-.089l4-5.5Z"%20clip-rule="evenodd"/></svg>');
-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 {
--hero-shopping-bag: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M15.75%2010.5V6a3.75%203.75%200%201%200-7.5%200v4.5m11.356-1.993%201.263%2012c.07.665-.45%201.243-1.119%201.243H4.25a1.125%201.125%200%200%201-1.12-1.243l1.264-12A1.125%201.125%200%200%201%205.513%207.5h12.974c.576%200%201.059.435%201.119%201.007ZM8.625%2010.5a.375.375%200%201%201-.75%200%20.375.375%200%200%201%20.75%200Zm7.5%200a.375.375%200%201%201-.75%200%20.375.375%200%200%201%20.75%200Z"/></svg>'); --hero-shopping-bag: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M15.75%2010.5V6a3.75%203.75%200%201%200-7.5%200v4.5m11.356-1.993%201.263%2012c.07.665-.45%201.243-1.119%201.243H4.25a1.125%201.125%200%200%201-1.12-1.243l1.264-12A1.125%201.125%200%200%201%205.513%207.5h12.974c.576%200%201.059.435%201.119%201.007ZM8.625%2010.5a.375.375%200%201%201-.75%200%20.375.375%200%200%201%20.75%200Zm7.5%200a.375.375%200%201%201-.75%200%20.375.375%200%200%201%20.75%200Z"/></svg>');
-webkit-mask: var(--hero-shopping-bag); -webkit-mask: var(--hero-shopping-bag);
@ -1034,18 +1058,6 @@
height: 1.5rem; height: 1.5rem;
} }
.hero-signal {
--hero-signal: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M9.348%2014.652a3.75%203.75%200%200%201%200-5.304m5.304%200a3.75%203.75%200%200%201%200%205.304m-7.425%202.121a6.75%206.75%200%200%201%200-9.546m9.546%200a6.75%206.75%200%200%201%200%209.546M5.106%2018.894c-3.808-3.807-3.808-9.98%200-13.788m13.788%200c3.808%203.807%203.808%209.98%200%2013.788M12%2012h.008v.008H12V12Zm.375%200a.375.375%200%201%201-.75%200%20.375.375%200%200%201%20.75%200Z"/></svg>');
-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 {
--hero-squares-2x2: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M3.75%206A2.25%202.25%200%200%201%206%203.75h2.25A2.25%202.25%200%200%201%2010.5%206v2.25a2.25%202.25%200%200%201-2.25%202.25H6a2.25%202.25%200%200%201-2.25-2.25V6ZM3.75%2015.75A2.25%202.25%200%200%201%206%2013.5h2.25a2.25%202.25%200%200%201%202.25%202.25V18a2.25%202.25%200%200%201-2.25%202.25H6A2.25%202.25%200%200%201%203.75%2018v-2.25ZM13.5%206a2.25%202.25%200%200%201%202.25-2.25H18A2.25%202.25%200%200%201%2020.25%206v2.25A2.25%202.25%200%200%201%2018%2010.5h-2.25a2.25%202.25%200%200%201-2.25-2.25V6ZM13.5%2015.75a2.25%202.25%200%200%201%202.25-2.25H18a2.25%202.25%200%200%201%202.25%202.25V18A2.25%202.25%200%200%201%2018%2020.25h-2.25A2.25%202.25%200%200%201%2013.5%2018v-2.25Z"/></svg>'); --hero-squares-2x2: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M3.75%206A2.25%202.25%200%200%201%206%203.75h2.25A2.25%202.25%200%200%201%2010.5%206v2.25a2.25%202.25%200%200%201-2.25%202.25H6a2.25%202.25%200%200%201-2.25-2.25V6ZM3.75%2015.75A2.25%202.25%200%200%201%206%2013.5h2.25a2.25%202.25%200%200%201%202.25%202.25V18a2.25%202.25%200%200%201-2.25%202.25H6A2.25%202.25%200%200%201%203.75%2018v-2.25ZM13.5%206a2.25%202.25%200%200%201%202.25-2.25H18A2.25%202.25%200%200%201%2020.25%206v2.25A2.25%202.25%200%200%201%2018%2010.5h-2.25a2.25%202.25%200%200%201-2.25-2.25V6ZM13.5%2015.75a2.25%202.25%200%200%201%202.25-2.25H18a2.25%202.25%200%200%201%202.25%202.25V18A2.25%202.25%200%200%201%2018%2020.25h-2.25A2.25%202.25%200%200%201%2013.5%2018v-2.25Z"/></svg>');
-webkit-mask: var(--hero-squares-2x2); -webkit-mask: var(--hero-squares-2x2);
@ -1166,6 +1178,18 @@
height: 1.5rem; height: 1.5rem;
} }
.hero-user-circle {
--hero-user-circle: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M17.982%2018.725A7.488%207.488%200%200%200%2012%2015.75a7.488%207.488%200%200%200-5.982%202.975m11.963%200a9%209%200%201%200-11.963%200m11.963%200A8.966%208.966%200%200%201%2012%2021a8.966%208.966%200%200%201-5.982-2.275M15%209.75a3%203%200%201%201-6%200%203%203%200%200%201%206%200Z"/></svg>');
-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 {
--hero-users: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M15%2019.128a9.38%209.38%200%200%200%202.625.372%209.337%209.337%200%200%200%204.121-.952%204.125%204.125%200%200%200-7.533-2.493M15%2019.128v-.003c0-1.113-.285-2.16-.786-3.07M15%2019.128v.106A12.318%2012.318%200%200%201%208.624%2021c-2.331%200-4.512-.645-6.374-1.766l-.001-.109a6.375%206.375%200%200%201%2011.964-3.07M12%206.375a3.375%203.375%200%201%201-6.75%200%203.375%203.375%200%200%201%206.75%200Zm8.25%202.25a2.625%202.625%200%201%201-5.25%200%202.625%202.625%200%200%201%205.25%200Z"/></svg>'); --hero-users: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="M15%2019.128a9.38%209.38%200%200%200%202.625.372%209.337%209.337%200%200%200%204.121-.952%204.125%204.125%200%200%200-7.533-2.493M15%2019.128v-.003c0-1.113-.285-2.16-.786-3.07M15%2019.128v.106A12.318%2012.318%200%200%201%208.624%2021c-2.331%200-4.512-.645-6.374-1.766l-.001-.109a6.375%206.375%200%200%201%2011.964-3.07M12%206.375a3.375%203.375%200%201%201-6.75%200%203.375%203.375%200%200%201%206.75%200Zm8.25%202.25a2.625%202.625%200%201%201-5.25%200%202.625%202.625%200%200%201%205.25%200Z"/></svg>');
-webkit-mask: var(--hero-users); -webkit-mask: var(--hero-users);
@ -1190,18 +1214,6 @@
height: 1.5rem; height: 1.5rem;
} }
.hero-x-circle {
--hero-x-circle: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20fill="none"%20viewBox="0%200%2024%2024"%20stroke-width="1.5"%20stroke="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20stroke-linecap="round"%20stroke-linejoin="round"%20d="m9.75%209.75%204.5%204.5m0-4.5-4.5%204.5M21%2012a9%209%200%201%201-18%200%209%209%200%200%201%2018%200Z"/></svg>');
-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 {
--hero-x-circle-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20fill-rule="evenodd"%20d="M10%2018a8%208%200%201%200%200-16%208%208%200%200%200%200%2016ZM8.28%207.22a.75.75%200%200%200-1.06%201.06L8.94%2010l-1.72%201.72a.75.75%200%201%200%201.06%201.06L10%2011.06l1.72%201.72a.75.75%200%201%200%201.06-1.06L11.06%2010l1.72-1.72a.75.75%200%200%200-1.06-1.06L10%208.94%208.28%207.22Z"%20clip-rule="evenodd"/></svg>'); --hero-x-circle-mini: url('data:image/svg+xml;utf8,<svg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2020%2020"%20fill="currentColor"%20aria-hidden="true"%20data-slot="icon">%20%20<path%20fill-rule="evenodd"%20d="M10%2018a8%208%200%201%200%200-16%208%208%200%200%200%200%2016ZM8.28%207.22a.75.75%200%200%200-1.06%201.06L8.94%2010l-1.72%201.72a.75.75%200%201%200%201.06%201.06L10%2011.06l1.72%201.72a.75.75%200%201%200%201.06-1.06L11.06%2010l1.72-1.72a.75.75%200%200%200-1.06-1.06L10%208.94%208.28%207.22Z"%20clip-rule="evenodd"/></svg>');
-webkit-mask: var(--hero-x-circle-mini); -webkit-mask: var(--hero-x-circle-mini);

View File

@ -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 // DirtyGuard + Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors
const EditorKeyboard = { const EditorKeyboard = {
mounted() { mounted() {
@ -788,7 +828,7 @@ const EditorKeyboard = {
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken, screen_width: window.innerWidth}, 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 // Show progress bar on live navigation and form submits

View File

@ -5,9 +5,13 @@ defmodule Berrypod.Accounts do
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Berrypod.Repo alias Berrypod.Repo
alias Berrypod.Vault
alias Berrypod.Accounts.{User, UserToken, UserNotifier} alias Berrypod.Accounts.{User, UserToken, UserNotifier}
@totp_issuer "Berrypod"
@backup_code_count 8
## Database getters ## Database getters
@doc """ @doc """
@ -342,4 +346,147 @@ defmodule Berrypod.Accounts do
end end
end) 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 end

View File

@ -11,9 +11,20 @@ defmodule Berrypod.Accounts.User do
field :confirmed_at, :utc_datetime field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true 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) timestamps(type: :utc_datetime)
end 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 """ @doc """
A user changeset for registering or changing the email. A user changeset for registering or changing the email.

View File

@ -34,6 +34,19 @@
<aside class="admin-sidebar"> <aside class="admin-sidebar">
<%!-- nav links --%> <%!-- nav links --%>
<nav class="admin-sidebar-nav" aria-label="Admin navigation"> <nav class="admin-sidebar-nav" aria-label="Admin navigation">
<div class="admin-nav-group">
<ul class="admin-nav">
<li>
<.link
navigate={~p"/admin/account"}
class={admin_nav_active?(@current_path, "/admin/account")}
>
<.icon name="hero-user-circle" class="size-5" /> Account
</.link>
</li>
</ul>
</div>
<div class="admin-nav-group"> <div class="admin-nav-group">
<span class="admin-nav-heading">Shop</span> <span class="admin-nav-heading">Shop</span>
<ul class="admin-nav"> <ul class="admin-nav">
@ -174,9 +187,6 @@
<%!-- sidebar footer --%> <%!-- sidebar footer --%>
<div class="admin-sidebar-footer"> <div class="admin-sidebar-footer">
<ul class="admin-nav"> <ul class="admin-nav">
<li class="admin-sidebar-email truncate">
<.icon name="hero-user" class="size-5" /> {@current_scope.user.email}
</li>
<li> <li>
<details class="admin-dev-tools"> <details class="admin-dev-tools">
<summary> <summary>

View File

@ -0,0 +1,63 @@
defmodule BerrypodWeb.AccountController do
@moduledoc """
Handles account-related session operations that can't be done in LiveView.
These routes manage TOTP setup state in the session, which persists across
LiveView reconnects on mobile devices.
"""
use BerrypodWeb, :controller
alias Berrypod.Accounts
@doc """
Starts TOTP setup by generating a secret and storing it in the session.
The session persists across LiveView reconnects.
"""
def start_totp_setup(conn, _params) do
user = conn.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
conn
|> put_flash(:error, "Please log in again to enable 2FA.")
|> redirect(to: ~p"/users/log-in?return_to=/admin/account")
else
{secret, _uri} = Accounts.generate_totp_secret(user)
conn
|> put_session(:totp_setup_secret, secret)
|> redirect(to: ~p"/admin/account")
end
end
@doc """
Clears the TOTP setup session state.
"""
def cancel_totp_setup(conn, _params) do
conn
|> delete_session(:totp_setup_secret)
|> redirect(to: ~p"/admin/account")
end
@doc """
Clears the TOTP setup session and stores backup codes for display.
Called via redirect from the LiveView after successful enablement.
"""
def complete_totp_setup(conn, %{"codes" => codes_param}) do
# Codes come as comma-separated string
backup_codes = String.split(codes_param, ",")
conn
|> delete_session(:totp_setup_secret)
|> put_session(:totp_backup_codes, backup_codes)
|> redirect(to: ~p"/admin/account")
end
@doc """
Clears the backup codes from the session after user confirms they've saved them.
"""
def clear_backup_codes(conn, _params) do
conn
|> delete_session(:totp_backup_codes)
|> redirect(to: ~p"/admin/account")
end
end

View File

@ -4,7 +4,7 @@ defmodule BerrypodWeb.UserSessionController do
alias Berrypod.Accounts alias Berrypod.Accounts
alias BerrypodWeb.UserAuth alias BerrypodWeb.UserAuth
plug BerrypodWeb.Plugs.RateLimit, [type: :login] when action == :create plug BerrypodWeb.Plugs.RateLimit, [type: :login] when action in [:create, :verify_totp]
def create(conn, %{"_action" => "confirmed"} = params) do def create(conn, %{"_action" => "confirmed"} = params) do
create(conn, params, "User confirmed successfully.") create(conn, params, "User confirmed successfully.")
@ -15,14 +15,14 @@ defmodule BerrypodWeb.UserSessionController do
end end
# magic link login # magic link login
defp create(conn, %{"user" => %{"token" => token} = user_params}, info) do defp create(conn, %{"user" => %{"token" => token} = user_params} = params, info) do
case Accounts.login_user_by_magic_link(token) do case Accounts.login_user_by_magic_link(token) do
{:ok, {user, tokens_to_disconnect}} -> {:ok, {user, tokens_to_disconnect}} ->
UserAuth.disconnect_sessions(tokens_to_disconnect) UserAuth.disconnect_sessions(tokens_to_disconnect)
conn conn
|> put_flash(:info, info) |> maybe_store_return_to(params)
|> UserAuth.log_in_user(user, user_params) |> maybe_require_totp(user, user_params, info)
_ -> _ ->
conn conn
@ -32,13 +32,13 @@ defmodule BerrypodWeb.UserSessionController do
end end
# email + password login # email + password login
defp create(conn, %{"user" => user_params}, info) do defp create(conn, %{"user" => user_params} = params, info) do
%{"email" => email, "password" => password} = user_params %{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do if user = Accounts.get_user_by_email_and_password(email, password) do
conn conn
|> put_flash(:info, info) |> maybe_store_return_to(params)
|> UserAuth.log_in_user(user, user_params) |> maybe_require_totp(user, user_params, info)
else else
# In order to prevent user enumeration attacks, don't disclose whether the email is registered. # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn conn
@ -48,6 +48,55 @@ defmodule BerrypodWeb.UserSessionController do
end end
end end
defp maybe_store_return_to(conn, %{"return_to" => "/" <> _ = return_to}) do
put_session(conn, :user_return_to, return_to)
end
defp maybe_store_return_to(conn, _params), do: conn
defp maybe_require_totp(conn, user, user_params, info) do
if Accounts.totp_enabled?(user) do
remember_me = user_params["remember_me"] == "true"
conn
|> put_session(:totp_pending_user_id, user.id)
|> put_session(:totp_pending_remember_me, remember_me)
|> redirect(to: ~p"/users/totp")
else
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
end
end
def verify_totp(conn, %{"totp" => %{"code" => code}, "remember_me" => remember_me}) do
user_id = get_session(conn, :totp_pending_user_id)
if user_id do
user = Accounts.get_user!(user_id)
case Accounts.verify_totp(user, code) do
:ok ->
user_params = if remember_me == "true", do: %{"remember_me" => "true"}, else: %{}
conn
|> delete_session(:totp_pending_user_id)
|> delete_session(:totp_pending_remember_me)
|> put_flash(:info, "Welcome back!")
|> UserAuth.log_in_user(user, user_params)
:error ->
conn
|> put_flash(:error, "Invalid code. Please try again.")
|> redirect(to: ~p"/users/totp")
end
else
conn
|> put_flash(:error, "Session expired. Please log in again.")
|> redirect(to: ~p"/users/log-in")
end
end
def update_password(conn, %{"user" => user_params} = params) do def update_password(conn, %{"user" => user_params} = params) do
user = conn.assigns.current_scope.user user = conn.assigns.current_scope.user
true = Accounts.sudo_mode?(user) true = Accounts.sudo_mode?(user)
@ -57,7 +106,7 @@ defmodule BerrypodWeb.UserSessionController do
UserAuth.disconnect_sessions(expired_tokens) UserAuth.disconnect_sessions(expired_tokens)
conn conn
|> put_session(:user_return_to, ~p"/admin/settings") |> put_session(:user_return_to, ~p"/admin/account")
|> create(params, "Password updated successfully!") |> create(params, "Password updated successfully!")
end end

View File

@ -0,0 +1,466 @@
defmodule BerrypodWeb.Admin.Account do
use BerrypodWeb, :live_view
alias Berrypod.Accounts
@impl true
def mount(_params, session, socket) do
user = socket.assigns.current_scope.user
# Restore TOTP setup from session if it exists (persists across reconnects)
totp_setup =
case session["totp_setup_secret"] do
nil -> nil
secret -> %{secret: secret, uri: Accounts.totp_uri(user, secret)}
end
# Check for backup codes from successful TOTP enablement
backup_codes = session["totp_backup_codes"]
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
{:ok,
socket
|> assign(:page_title, "Account")
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
|> assign(:totp_enabled, Accounts.totp_enabled?(user))
|> assign(:totp_setup, totp_setup)
|> assign(:totp_form, to_form(%{"code" => ""}, as: :totp))
|> assign(:backup_codes, backup_codes)
|> assign(:disable_totp_form, nil)}
end
# -- Events: email --
@impl true
def handle_event("validate_email", %{"user" => user_params}, socket) do
email_form =
socket.assigns.current_scope.user
|> Accounts.change_user_email(user_params, validate_unique: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form)}
end
def handle_event("update_email", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
{:noreply,
socket
|> put_flash(:error, "Please log in again to change account settings.")
|> redirect(to: ~p"/users/log-in?return_to=/admin/account")}
else
case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&url(~p"/users/settings/confirm-email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, put_flash(socket, :info, info)}
changeset ->
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
end
end
end
# -- Events: password --
def handle_event("validate_password", %{"user" => user_params}, socket) do
password_form =
socket.assigns.current_scope.user
|> Accounts.change_user_password(user_params, hash_password: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form)}
end
def handle_event("update_password", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
{:noreply,
socket
|> put_flash(:error, "Please log in again to change account settings.")
|> redirect(to: ~p"/users/log-in?return_to=/admin/account")}
else
case Accounts.change_user_password(user, user_params) do
%{valid?: true} = changeset ->
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
changeset ->
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
end
end
end
# -- Events: 2FA --
# Note: start_totp_setup and cancel_totp_setup are handled by the controller
# (POST /admin/account/totp/start and /admin/account/totp/cancel) to persist
# the secret in the session, which survives LiveView reconnects on mobile.
def handle_event("verify_totp", %{"totp" => %{"code" => code}}, socket) do
user = socket.assigns.current_scope.user
setup = socket.assigns.totp_setup
case Accounts.enable_totp(user, setup.secret, code) do
{:ok, _user, backup_codes} ->
# Redirect through controller to clear the session and pass backup codes
# This ensures reconnects don't show the setup flow again
codes_param = Enum.join(backup_codes, ",")
{:noreply,
socket
|> put_flash(:info, "Two-factor authentication enabled")
|> redirect(to: ~p"/admin/account/totp/complete?codes=#{codes_param}")}
{:error, :invalid_code} ->
{:noreply,
socket
|> assign(:totp_form, to_form(%{"code" => ""}, as: :totp))
|> put_flash(:error, "Invalid code. Please try again.")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")}
end
end
def handle_event("start_disable_totp", _params, socket) do
user = socket.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
{:noreply,
socket
|> put_flash(:error, "Please log in again to disable 2FA.")
|> redirect(to: ~p"/users/log-in?return_to=/admin/account")}
else
{:noreply, assign(socket, :disable_totp_form, to_form(%{"code" => ""}, as: :disable_totp))}
end
end
def handle_event("cancel_disable_totp", _params, socket) do
{:noreply, assign(socket, :disable_totp_form, nil)}
end
def handle_event("confirm_disable_totp", %{"disable_totp" => %{"code" => code}}, socket) do
user = socket.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
{:noreply,
socket
|> put_flash(:error, "Please log in again to disable 2FA.")
|> redirect(to: ~p"/users/log-in?return_to=/admin/account")}
else
case Accounts.verify_totp(user, code) do
:ok ->
case Accounts.disable_totp(user) do
{:ok, _user} ->
{:noreply,
socket
|> assign(:totp_enabled, false)
|> assign(:disable_totp_form, nil)
|> put_flash(:info, "Two-factor authentication disabled")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")}
end
:error ->
{:noreply,
socket
|> assign(:disable_totp_form, to_form(%{"code" => ""}, as: :disable_totp))
|> put_flash(:error, "Invalid code. Please try again.")}
end
end
end
# -- Render --
@impl true
def render(assigns) do
~H"""
<div class="admin-settings">
<.header>
Account
</.header>
<%!-- Email --%>
<section class="admin-section">
<h2 class="admin-section-title">Email</h2>
<div class="admin-section-body">
<.form
for={@email_form}
id="email_form"
phx-submit="update_email"
phx-change="validate_email"
>
<.input
field={@email_form[:email]}
type="email"
label="Email address"
autocomplete="username"
required
/>
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Change email</.button>
</div>
</.form>
</div>
</section>
<%!-- Password --%>
<section class="admin-section">
<h2 class="admin-section-title">Password</h2>
<div class="admin-section-body">
<.form
for={@password_form}
id="password_form"
action={~p"/users/update-password"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
autocomplete="username"
value={@current_email}
/>
<.input
field={@password_form[:password]}
type="password"
label="New password"
autocomplete="new-password"
required
/>
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
/>
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Change password</.button>
</div>
</.form>
</div>
</section>
<%!-- Two-factor authentication --%>
<section class="admin-section">
<div class="admin-section-header">
<h2 class="admin-section-title">Two-factor authentication</h2>
<%= if @totp_enabled do %>
<.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> Enabled
</.status_pill>
<% else %>
<.status_pill color="zinc">Off</.status_pill>
<% end %>
</div>
<%= if @backup_codes do %>
<%!-- Show backup codes after enabling --%>
<div class="admin-section-body">
<div class="admin-info-box admin-info-box-amber">
<p class="admin-text-bold">Save your backup codes</p>
<p>
These codes can be used to access your account if you lose your authenticator.
Each code can only be used once. Store them somewhere safe.
</p>
<div class="admin-backup-codes">
<%= for code <- @backup_codes do %>
<code class="admin-backup-code">{code}</code>
<% end %>
</div>
<div class="admin-form-actions-sm">
<.link
href={~p"/admin/account/totp/dismiss-codes"}
method="post"
data-confirm="Are you sure? You won't be able to see these codes again."
class="admin-btn admin-btn-primary admin-btn-sm"
>
I've saved these codes
</.link>
</div>
</div>
</div>
<% else %>
<%= if @totp_setup do %>
<%!-- Setup flow --%>
<div class="admin-section-body">
<p class="admin-section-desc admin-section-desc-flush">
Add this account to your authenticator app, then enter the 6-digit code to verify.
</p>
<div class="admin-totp-setup">
<%!-- QR code for scanning --%>
<div class="admin-totp-qr">
<div class="admin-qr-code">
{raw(qr_code_svg(@totp_setup.uri))}
</div>
<p class="admin-help-text">
Scan with your authenticator app
</p>
</div>
<div class="admin-totp-divider">
<span>or copy the key</span>
</div>
<%!-- Copyable secret key --%>
<div class="admin-totp-copy">
<div class="admin-copy-field">
<code
id="totp-secret"
class="admin-code-break"
>
{Base.encode32(@totp_setup.secret)}
</code>
<button
id="copy-totp-secret"
type="button"
phx-hook="Clipboard"
data-copy-target="totp-secret"
class="admin-btn admin-btn-outline admin-btn-sm"
>
<.icon name="hero-clipboard" class="size-4" />
<span>Copy</span>
</button>
</div>
<p class="admin-help-text">
Paste into your authenticator app as a manual entry (time-based/TOTP)
</p>
</div>
</div>
<.form for={@totp_form} phx-submit="verify_totp" class="admin-stack">
<.input
field={@totp_form[:code]}
type="text"
label="Verification code"
placeholder="000000"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
maxlength="6"
/>
<div class="admin-cluster">
<.button phx-disable-with="Verifying...">Enable 2FA</.button>
<.link
href={~p"/admin/account/totp/cancel"}
method="post"
class="admin-btn admin-btn-outline"
>
Cancel
</.link>
</div>
</.form>
</div>
<% else %>
<%!-- Enable/disable --%>
<%= if @disable_totp_form do %>
<%!-- Verification form for disabling --%>
<div class="admin-section-body">
<p class="admin-section-desc admin-section-desc-flush">
Enter a code from your authenticator app to confirm disabling 2FA.
</p>
<.form for={@disable_totp_form} phx-submit="confirm_disable_totp" class="admin-stack">
<.input
field={@disable_totp_form[:code]}
type="text"
label="Verification code"
placeholder="000000"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
maxlength="6"
/>
<div class="admin-cluster">
<.button class="admin-btn-danger" phx-disable-with="Disabling...">
Disable 2FA
</.button>
<button
type="button"
phx-click="cancel_disable_totp"
class="admin-btn admin-btn-outline"
>
Cancel
</button>
</div>
</.form>
</div>
<% else %>
<p class="admin-section-desc">
<%= if @totp_enabled do %>
Your account is protected with two-factor authentication.
<% else %>
Add an extra layer of security by requiring a code from your authenticator app when logging in.
<% end %>
</p>
<div class="admin-section-body">
<%= if @totp_enabled do %>
<button
phx-click="start_disable_totp"
class="admin-btn admin-btn-outline admin-btn-sm"
>
Disable 2FA
</button>
<% else %>
<.link
href={~p"/admin/account/totp/start"}
method="post"
class="admin-btn admin-btn-primary admin-btn-sm"
>
<.icon name="hero-shield-check-mini" class="size-4" /> Enable 2FA
</.link>
<% end %>
</div>
<% end %>
<% end %>
<% end %>
</section>
</div>
"""
end
# -- Function components --
attr :color, :string, required: true
slot :inner_block, required: true
defp status_pill(assigns) do
modifier =
case assigns.color do
"green" -> "admin-status-pill-green"
"amber" -> "admin-status-pill-amber"
_ -> "admin-status-pill-zinc"
end
assigns = assign(assigns, :modifier, modifier)
~H"""
<span class={["admin-status-pill", @modifier]}>
{render_slot(@inner_block)}
</span>
"""
end
defp qr_code_svg(uri) do
uri
|> EQRCode.encode()
|> EQRCode.svg(width: 200)
end
end

View File

@ -1,7 +1,6 @@
defmodule BerrypodWeb.Admin.Settings do defmodule BerrypodWeb.Admin.Settings do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.Accounts
alias Berrypod.Products alias Berrypod.Products
alias Berrypod.Settings alias Berrypod.Settings
alias Berrypod.Stripe.Setup, as: StripeSetup alias Berrypod.Stripe.Setup, as: StripeSetup
@ -19,8 +18,7 @@ defmodule BerrypodWeb.Admin.Settings do
|> assign(:from_address_status, :idle) |> assign(:from_address_status, :idle)
|> assign(:signing_secret_status, :idle) |> assign(:signing_secret_status, :idle)
|> assign_stripe_state() |> assign_stripe_state()
|> assign_products_state() |> assign_products_state()}
|> assign_account_state(user)}
end end
# -- Stripe assigns -- # -- Stripe assigns --
@ -66,19 +64,6 @@ defmodule BerrypodWeb.Admin.Settings do
assign(socket, :provider, connection_info) assign(socket, :provider, connection_info)
end end
# -- Account assigns --
defp assign_account_state(socket, user) do
email_changeset = Accounts.change_user_email(user, %{}, validate_unique: false)
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
socket
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
end
# -- Events: shop status -- # -- Events: shop status --
@impl true @impl true
@ -232,73 +217,6 @@ defmodule BerrypodWeb.Admin.Settings do
|> put_flash(:info, "Provider connection deleted")} |> put_flash(:info, "Provider connection deleted")}
end end
# -- Events: account --
def handle_event("validate_email", %{"user" => user_params}, socket) do
email_form =
socket.assigns.current_scope.user
|> Accounts.change_user_email(user_params, validate_unique: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form)}
end
def handle_event("update_email", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
{:noreply,
socket
|> put_flash(:error, "Please log in again to change account settings.")
|> redirect(to: ~p"/users/log-in")}
else
case Accounts.change_user_email(user, user_params) do
%{valid?: true} = changeset ->
Accounts.deliver_user_update_email_instructions(
Ecto.Changeset.apply_action!(changeset, :insert),
user.email,
&url(~p"/users/settings/confirm-email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, put_flash(socket, :info, info)}
changeset ->
{:noreply, assign(socket, :email_form, to_form(changeset, action: :insert))}
end
end
end
def handle_event("validate_password", %{"user" => user_params}, socket) do
password_form =
socket.assigns.current_scope.user
|> Accounts.change_user_password(user_params, hash_password: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form)}
end
def handle_event("update_password", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
unless Accounts.sudo_mode?(user) do
{:noreply,
socket
|> put_flash(:error, "Please log in again to change account settings.")
|> redirect(to: ~p"/users/log-in")}
else
case Accounts.change_user_password(user, user_params) do
%{valid?: true} = changeset ->
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
changeset ->
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
end
end
end
# -- Render -- # -- Render --
@impl true @impl true
@ -470,81 +388,6 @@ defmodule BerrypodWeb.Admin.Settings do
</form> </form>
</div> </div>
</section> </section>
<%!-- Account --%>
<section class="admin-section">
<h2 class="admin-section-title">Account</h2>
<div class="admin-stack admin-stack-lg admin-section-body">
<.form
for={@email_form}
id="email_form"
phx-submit="update_email"
phx-change="validate_email"
>
<.input
field={@email_form[:email]}
type="email"
label="Email"
autocomplete="username"
required
/>
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Change email</.button>
</div>
</.form>
<div class="admin-separator-xl">
<.form
for={@password_form}
id="password_form"
action={~p"/users/update-password"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
autocomplete="username"
value={@current_email}
/>
<.input
field={@password_form[:password]}
type="password"
label="New password"
autocomplete="new-password"
required
/>
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
/>
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Change password</.button>
</div>
</.form>
</div>
</div>
</section>
<%!-- Advanced --%>
<section class="admin-section">
<h2 class="admin-section-title">Advanced</h2>
<div class="admin-stack admin-stack-sm admin-section-body">
<.link href={~p"/admin/dashboard"} class="admin-link-subtle">
<.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard
</.link>
<.link href={~p"/admin/errors"} class="admin-link-subtle">
<.icon name="hero-bug-ant" class="size-4 inline" /> Error tracker
</.link>
</div>
</section>
</div> </div>
""" """
end end

View File

@ -39,6 +39,7 @@ defmodule BerrypodWeb.Auth.Login do
action={~p"/users/log-in"} action={~p"/users/log-in"}
phx-submit="submit_magic" phx-submit="submit_magic"
> >
<input :if={@return_to} type="hidden" name="return_to" value={@return_to} />
<.input <.input
readonly={!!@current_scope} readonly={!!@current_scope}
field={f[:email]} field={f[:email]}
@ -64,6 +65,7 @@ defmodule BerrypodWeb.Auth.Login do
phx-submit="submit_password" phx-submit="submit_password"
phx-trigger-action={@trigger_submit} phx-trigger-action={@trigger_submit}
> >
<input :if={@return_to} type="hidden" name="return_to" value={@return_to} />
<.input <.input
readonly={!!@current_scope} readonly={!!@current_scope}
field={f[:email]} field={f[:email]}
@ -103,18 +105,20 @@ defmodule BerrypodWeb.Auth.Login do
end end
@impl true @impl true
def mount(_params, _session, socket) do def mount(params, _session, socket) do
email = email =
Phoenix.Flash.get(socket.assigns.flash, :email) || Phoenix.Flash.get(socket.assigns.flash, :email) ||
get_in(socket.assigns, [:current_scope, Access.key(:user), Access.key(:email)]) get_in(socket.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
form = to_form(%{"email" => email}, as: "user") form = to_form(%{"email" => email}, as: "user")
return_to = params["return_to"]
{:ok, {:ok,
assign(socket, assign(socket,
form: form, form: form,
trigger_submit: false, trigger_submit: false,
email_configured: Mailer.email_verified?() email_configured: Mailer.email_verified?(),
return_to: return_to
)} )}
end end

View File

@ -0,0 +1,90 @@
defmodule BerrypodWeb.Auth.TotpVerification do
use BerrypodWeb, :live_view
alias Berrypod.Accounts
@impl true
def mount(_params, session, socket) do
user_id = session["totp_pending_user_id"]
remember_me = session["totp_pending_remember_me"]
if user_id do
{:ok,
socket
|> assign(:user_id, user_id)
|> assign(:remember_me, remember_me)
|> assign(:form, to_form(%{"code" => ""}, as: :totp))
|> assign(:trigger_submit, false)}
else
{:ok,
socket
|> put_flash(:error, "Session expired. Please log in again.")
|> redirect(to: ~p"/users/log-in")}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="setup-page">
<div class="setup-header">
<.header>
Two-factor authentication
<:subtitle>
Enter the 6-digit code from your authenticator app.
</:subtitle>
</.header>
</div>
<.form
for={@form}
id="totp_form"
action={~p"/users/verify-totp"}
phx-submit="verify"
phx-trigger-action={@trigger_submit}
>
<input type="hidden" name="remember_me" value={to_string(@remember_me)} />
<.input
field={@form[:code]}
type="text"
label="Verification code"
placeholder="000000"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
maxlength="6"
autofocus
phx-mounted={JS.focus()}
/>
<.button variant="primary" class="admin-btn-block">
Verify <span aria-hidden="true">&rarr;</span>
</.button>
</.form>
<p class="setup-footer admin-text-tertiary">
Lost your device? Enter one of your backup codes instead.
</p>
</div>
</Layouts.app>
"""
end
@impl true
def handle_event("verify", %{"totp" => %{"code" => code}}, socket) do
user = Accounts.get_user!(socket.assigns.user_id)
case Accounts.verify_totp(user, code) do
:ok ->
{:noreply, assign(socket, :trigger_submit, true)}
:error ->
{:noreply,
socket
|> assign(:form, to_form(%{"code" => ""}, as: :totp))
|> put_flash(:error, "Invalid code. Please try again.")}
end
end
end

View File

@ -143,6 +143,11 @@ defmodule BerrypodWeb.Router do
post "/settings/email/test", EmailSettingsController, :test post "/settings/email/test", EmailSettingsController, :test
post "/settings/from-address", SettingsController, :update_from_address post "/settings/from-address", SettingsController, :update_from_address
post "/settings/stripe/signing-secret", SettingsController, :update_signing_secret post "/settings/stripe/signing-secret", SettingsController, :update_signing_secret
# Account TOTP routes (session-based for mobile reconnect persistence)
post "/account/totp/start", AccountController, :start_totp_setup
post "/account/totp/cancel", AccountController, :cancel_totp_setup
get "/account/totp/complete", AccountController, :complete_totp_setup
post "/account/totp/dismiss-codes", AccountController, :clear_backup_codes
post "/navigation", NavigationController, :save post "/navigation", NavigationController, :save
post "/providers", ProvidersController, :create post "/providers", ProvidersController, :create
post "/providers/:id", ProvidersController, :update post "/providers/:id", ProvidersController, :update
@ -165,6 +170,7 @@ defmodule BerrypodWeb.Router do
live "/providers/:id/edit", Admin.Providers.Form, :edit live "/providers/:id/edit", Admin.Providers.Form, :edit
live "/settings", Admin.Settings, :index live "/settings", Admin.Settings, :index
live "/settings/email", Admin.EmailSettings, :index live "/settings/email", Admin.EmailSettings, :index
live "/account", Admin.Account, :index
live "/pages", Admin.Pages.Index, :index live "/pages", Admin.Pages.Index, :index
live "/pages/new", Admin.Pages.CustomForm, :new live "/pages/new", Admin.Pages.CustomForm, :new
live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit
@ -208,9 +214,11 @@ defmodule BerrypodWeb.Router do
live "/users/register", Auth.Registration, :new live "/users/register", Auth.Registration, :new
live "/users/log-in", Auth.Login, :new live "/users/log-in", Auth.Login, :new
live "/users/log-in/:token", Auth.Confirmation, :new live "/users/log-in/:token", Auth.Confirmation, :new
live "/users/totp", Auth.TotpVerification, :new
end end
post "/users/log-in", UserSessionController, :create post "/users/log-in", UserSessionController, :create
post "/users/verify-totp", UserSessionController, :verify_totp
delete "/users/log-out", UserSessionController, :delete delete "/users/log-out", UserSessionController, :delete
end end

View File

@ -81,7 +81,9 @@ defmodule Berrypod.MixProject do
{:logger_json, "~> 7.0", only: :prod}, {:logger_json, "~> 7.0", only: :prod},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:hammer, "~> 7.0"} {:hammer, "~> 7.0"},
{:nimble_totp, "~> 1.0"},
{:eqrcode, "~> 0.2"}
] ]
end end

View File

@ -17,6 +17,7 @@
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"}, "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"eqrcode": {:hex, :eqrcode, "0.2.1", "d12838813e8fc87b8940cc05f9baadb189031f6009facdc56ff074375ec73b6e", [:mix], [], "hexpm", "d5828a222b904c68360e7dc2a40c3ef33a1328b7c074583898040f389f928025"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"error_tracker": {:hex, :error_tracker, "0.7.1", "074f10bfb01961bd0d51eb010c6ddbe13bf40eb4ddfbb25538680ddb520ab1aa", [:mix], [{:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, ">= 0.0.0", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:myxql, ">= 0.0.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:phoenix_ecto, "~> 4.6", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "cc77434c084d100e17dcc5ccea6bed05b15748186df5207e21fb761651bf4e0d"}, "error_tracker": {:hex, :error_tracker, "0.7.1", "074f10bfb01961bd0d51eb010c6ddbe13bf40eb4ddfbb25538680ddb520ab1aa", [:mix], [{:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, ">= 0.0.0", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:myxql, ">= 0.0.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:phoenix_ecto, "~> 4.6", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "cc77434c084d100e17dcc5ccea6bed05b15748186df5207e21fb761651bf4e0d"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
@ -50,6 +51,7 @@
"nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"},
"oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"}, "oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"},
"oban_met": {:hex, :oban_met, "1.0.6", "2a5500aff496b7ac4b830b0b03b08e920625a051bb6890981fbb53b15f1cbdc0", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "15ea3303de76225878a8e6c25a9d62bd1e2e9dd1c46ac8487d873b9f99e8dcee"}, "oban_met": {:hex, :oban_met, "1.0.6", "2a5500aff496b7ac4b830b0b03b08e920625a051bb6890981fbb53b15f1cbdc0", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "15ea3303de76225878a8e6c25a9d62bd1e2e9dd1c46ac8487d873b9f99e8dcee"},
"oban_web": {:hex, :oban_web, "2.11.8", "be6521b5b1eb6d4182f40f5acc948ea65d243451b94c26f06a7329575748f695", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "d0c04a836d929ef037e96be142285238275aabbafe62543bbdcc3f541d29ec30"}, "oban_web": {:hex, :oban_web, "2.11.8", "be6521b5b1eb6d4182f40f5acc948ea65d243451b94c26f06a7329575748f695", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "d0c04a836d929ef037e96be142285238275aabbafe62543bbdcc3f541d29ec30"},

View File

@ -0,0 +1,14 @@
defmodule Berrypod.Repo.Migrations.AddTotpToUsers do
use Ecto.Migration
def change do
alter table(:users) do
# Encrypted TOTP secret (20 bytes base32-encoded = 32 chars, but encrypted is larger)
add :totp_secret_encrypted, :binary
# When 2FA was enabled
add :totp_enabled_at, :utc_datetime
# Backup codes (encrypted JSON array of hashed codes)
add :totp_backup_codes_encrypted, :binary
end
end
end

View File

@ -466,4 +466,104 @@ defmodule Berrypod.AccountsTest do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\"" refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end end
end end
describe "TOTP 2FA" do
setup do
%{user: user_fixture()}
end
test "generate_totp_secret/1 returns a secret and URI", %{user: user} do
{secret, uri} = Accounts.generate_totp_secret(user)
assert is_binary(secret)
assert byte_size(secret) == 20
assert String.starts_with?(uri, "otpauth://totp/Berrypod:")
assert uri =~ user.email
end
test "totp_uri/2 regenerates the same URI format", %{user: user} do
{secret, original_uri} = Accounts.generate_totp_secret(user)
regenerated_uri = Accounts.totp_uri(user, secret)
assert original_uri == regenerated_uri
end
test "enable_totp/3 enables TOTP with valid code", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
assert {:ok, updated_user, backup_codes} = Accounts.enable_totp(user, secret, code)
assert updated_user.totp_enabled_at
assert updated_user.totp_secret_encrypted
assert updated_user.totp_backup_codes_encrypted
assert length(backup_codes) == 8
assert Enum.all?(backup_codes, &(String.length(&1) == 8))
end
test "enable_totp/3 fails with invalid code", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
assert {:error, :invalid_code} = Accounts.enable_totp(user, secret, "000000")
end
test "totp_enabled?/1 returns true when TOTP is enabled", %{user: user} do
refute Accounts.totp_enabled?(user)
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
{:ok, updated_user, _backup_codes} = Accounts.enable_totp(user, secret, code)
assert Accounts.totp_enabled?(updated_user)
end
test "verify_totp/2 accepts valid TOTP code", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
{:ok, user_with_totp, _backup_codes} = Accounts.enable_totp(user, secret, code)
# Generate a new valid code for verification
new_code = NimbleTOTP.verification_code(secret)
assert :ok = Accounts.verify_totp(user_with_totp, new_code)
end
test "verify_totp/2 rejects invalid TOTP code", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
{:ok, user_with_totp, _backup_codes} = Accounts.enable_totp(user, secret, code)
assert :error = Accounts.verify_totp(user_with_totp, "000000")
end
test "verify_totp/2 accepts valid backup code and consumes it", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
{:ok, user_with_totp, backup_codes} = Accounts.enable_totp(user, secret, code)
[first_backup | _rest] = backup_codes
# First use should succeed
assert :ok = Accounts.verify_totp(user_with_totp, first_backup)
# Reload user to get updated backup codes
updated_user = Accounts.get_user!(user.id)
# Second use of same code should fail
assert :error = Accounts.verify_totp(updated_user, first_backup)
end
test "disable_totp/1 removes TOTP configuration", %{user: user} do
{secret, _uri} = Accounts.generate_totp_secret(user)
code = NimbleTOTP.verification_code(secret)
{:ok, user_with_totp, _backup_codes} = Accounts.enable_totp(user, secret, code)
assert Accounts.totp_enabled?(user_with_totp)
{:ok, disabled_user} = Accounts.disable_totp(user_with_totp)
refute Accounts.totp_enabled?(disabled_user)
refute disabled_user.totp_secret_encrypted
refute disabled_user.totp_backup_codes_encrypted
refute disabled_user.totp_enabled_at
end
end
end end

View File

@ -0,0 +1,101 @@
defmodule Berrypod.RateLimitTest do
use ExUnit.Case, async: true
alias Berrypod.RateLimit
describe "check_login/1" do
test "allows requests within limit" do
ip = {192, 168, 1, unique_integer()}
for _i <- 1..5 do
assert :ok = RateLimit.check_login(ip)
end
end
test "blocks requests exceeding limit" do
ip = {192, 168, 2, unique_integer()}
for _i <- 1..5 do
assert :ok = RateLimit.check_login(ip)
end
assert {:error, retry_after} = RateLimit.check_login(ip)
assert is_integer(retry_after)
assert retry_after > 0
end
test "handles string IP addresses" do
ip = "10.0.0.#{unique_integer()}"
assert :ok = RateLimit.check_login(ip)
end
end
describe "check_magic_link/1" do
test "allows requests within limit" do
email = "test#{unique_integer()}@example.com"
for _i <- 1..3 do
assert :ok = RateLimit.check_magic_link(email)
end
end
test "blocks requests exceeding limit" do
email = "blocked#{unique_integer()}@example.com"
for _i <- 1..3 do
assert :ok = RateLimit.check_magic_link(email)
end
assert {:error, retry_after} = RateLimit.check_magic_link(email)
assert is_integer(retry_after)
assert retry_after > 0
end
end
describe "check_newsletter/1" do
test "allows requests within limit" do
ip = {172, 16, 1, unique_integer()}
for _i <- 1..10 do
assert :ok = RateLimit.check_newsletter(ip)
end
end
test "blocks requests exceeding limit" do
ip = {172, 16, 2, unique_integer()}
for _i <- 1..10 do
assert :ok = RateLimit.check_newsletter(ip)
end
assert {:error, retry_after} = RateLimit.check_newsletter(ip)
assert is_integer(retry_after)
end
end
describe "check_api/1" do
test "allows requests within limit" do
ip = {10, 10, 1, unique_integer()}
for _i <- 1..60 do
assert :ok = RateLimit.check_api(ip)
end
end
test "blocks requests exceeding limit" do
ip = {10, 10, 2, unique_integer()}
for _i <- 1..60 do
assert :ok = RateLimit.check_api(ip)
end
assert {:error, retry_after} = RateLimit.check_api(ip)
assert is_integer(retry_after)
end
end
defp unique_integer do
System.unique_integer([:positive]) |> rem(256)
end
end

View File

@ -0,0 +1,123 @@
defmodule BerrypodWeb.Admin.AccountTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
alias Berrypod.Accounts
setup do
user = user_fixture()
%{user: user}
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin/account")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "account page" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn, user: user}
end
test "renders email and password forms", %{conn: conn, user: user} do
{:ok, view, html} = live(conn, ~p"/admin/account")
assert html =~ "Account"
assert html =~ user.email
assert has_element?(view, "#email_form")
assert has_element?(view, "#password_form")
end
test "validates email change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/account")
result =
view
|> element("#email_form")
|> render_change(%{"user" => %{"email" => "with spaces"}})
assert result =~ "must have the @ sign and no spaces"
end
test "submits email change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/account")
result =
view
|> form("#email_form", %{"user" => %{"email" => unique_user_email()}})
|> render_submit()
assert result =~ "A link to confirm your email"
end
test "validates password", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/account")
result =
view
|> element("#password_form")
|> render_change(%{
"user" => %{
"password" => "short",
"password_confirmation" => "mismatch"
}
})
assert result =~ "should be at least 12 character(s)"
end
test "submits valid password change", %{conn: conn, user: user} do
new_password = valid_user_password()
{:ok, view, _html} = live(conn, ~p"/admin/account")
form =
form(view, "#password_form", %{
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
render_submit(form)
new_password_conn = follow_trigger_action(form, conn)
assert redirected_to(new_password_conn) == ~p"/admin/account"
assert Accounts.get_user_by_email_and_password(user.email, new_password)
end
end
describe "two-factor authentication" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn, user: user}
end
test "shows 2FA section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/account")
assert html =~ "Two-factor authentication"
assert html =~ "Off"
assert html =~ "Enable 2FA"
end
test "shows enabled state when TOTP is enabled", %{conn: conn, user: user} do
# Enable TOTP for the user
secret = NimbleTOTP.secret()
code = NimbleTOTP.verification_code(secret)
{:ok, _user, _codes} = Accounts.enable_totp(user, secret, code)
{:ok, _view, html} = live(conn, ~p"/admin/account")
assert html =~ "Two-factor authentication"
assert html =~ "Enabled"
assert html =~ "Disable 2FA"
end
end
end

View File

@ -37,12 +37,6 @@ defmodule BerrypodWeb.Admin.LayoutTest do
refute has_element?(view, ~s(a.active[href="/admin/orders"])) refute has_element?(view, ~s(a.active[href="/admin/orders"]))
end end
test "shows user email in sidebar", %{conn: conn, user: user} do
{:ok, _view, html} = live(conn, ~p"/admin/orders")
assert html =~ user.email
end
test "shows shop and log out links", %{conn: conn} do test "shows shop and log out links", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/orders") {:ok, view, _html} = live(conn, ~p"/admin/orders")

View File

@ -5,7 +5,6 @@ defmodule BerrypodWeb.Admin.SettingsTest do
import Berrypod.AccountsFixtures import Berrypod.AccountsFixtures
import Berrypod.ProductsFixtures import Berrypod.ProductsFixtures
alias Berrypod.Accounts
alias Berrypod.Settings alias Berrypod.Settings
setup do setup do
@ -166,80 +165,6 @@ defmodule BerrypodWeb.Admin.SettingsTest do
end end
end end
describe "account section" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn, user: user}
end
test "renders email and password forms", %{conn: conn, user: user} do
{:ok, view, html} = live(conn, ~p"/admin/settings")
assert html =~ "Account"
assert html =~ user.email
assert has_element?(view, "#email_form")
assert has_element?(view, "#password_form")
end
test "validates email change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
result =
view
|> element("#email_form")
|> render_change(%{"user" => %{"email" => "with spaces"}})
assert result =~ "must have the @ sign and no spaces"
end
test "submits email change", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
result =
view
|> form("#email_form", %{"user" => %{"email" => unique_user_email()}})
|> render_submit()
assert result =~ "A link to confirm your email"
end
test "validates password", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
result =
view
|> element("#password_form")
|> render_change(%{
"user" => %{
"password" => "short",
"password_confirmation" => "mismatch"
}
})
assert result =~ "should be at least 12 character(s)"
end
test "submits valid password change", %{conn: conn, user: user} do
new_password = valid_user_password()
{:ok, view, _html} = live(conn, ~p"/admin/settings")
form =
form(view, "#password_form", %{
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
render_submit(form)
new_password_conn = follow_trigger_action(form, conn)
assert redirected_to(new_password_conn) == ~p"/admin/settings"
assert Accounts.get_user_by_email_and_password(user.email, new_password)
end
end
describe "from address" do describe "from address" do
setup %{conn: conn, user: user} do setup %{conn: conn, user: user} do
conn = log_in_user(conn, user) conn = log_in_user(conn, user)
@ -256,27 +181,12 @@ defmodule BerrypodWeb.Admin.SettingsTest do
test "saves from address", %{conn: conn} do test "saves from address", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings") {:ok, view, _html} = live(conn, ~p"/admin/settings")
html = view
view |> form("form[phx-submit=\"save_from_address\"]", %{from_address: "shop@example.com"})
|> form("form[phx-submit=\"save_from_address\"]", %{from_address: "shop@example.com"}) |> render_submit()
|> render_submit()
assert has_element?(view, ".admin-inline-feedback-saved") assert has_element?(view, ".admin-inline-feedback-saved")
assert Settings.get_setting("email_from_address") == "shop@example.com" assert Settings.get_setting("email_from_address") == "shop@example.com"
end end
end end
describe "advanced section" do
setup %{conn: conn, user: user} do
conn = log_in_user(conn, user)
%{conn: conn}
end
test "shows links to system tools", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/settings")
assert has_element?(view, ~s(a[href="/admin/dashboard"]), "System dashboard")
assert has_element?(view, ~s(a[href="/admin/errors"]), "Error tracker")
end
end
end end