separate account settings from shop settings
All checks were successful
deploy / deploy (push) Successful in 3m28s
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:
parent
0c2d4ac406
commit
32cc425458
@ -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 {
|
||||
|
||||
@ -242,6 +242,18 @@
|
||||
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: 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);
|
||||
@ -374,6 +386,18 @@
|
||||
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: 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);
|
||||
@ -542,18 +566,6 @@
|
||||
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: 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);
|
||||
@ -1010,6 +1022,18 @@
|
||||
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: 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);
|
||||
@ -1034,18 +1058,6 @@
|
||||
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: 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);
|
||||
@ -1166,6 +1178,18 @@
|
||||
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: 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);
|
||||
@ -1190,18 +1214,6 @@
|
||||
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: 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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -34,6 +34,19 @@
|
||||
<aside class="admin-sidebar">
|
||||
<%!-- nav links --%>
|
||||
<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">
|
||||
<span class="admin-nav-heading">Shop</span>
|
||||
<ul class="admin-nav">
|
||||
@ -174,9 +187,6 @@
|
||||
<%!-- sidebar footer --%>
|
||||
<div class="admin-sidebar-footer">
|
||||
<ul class="admin-nav">
|
||||
<li class="admin-sidebar-email truncate">
|
||||
<.icon name="hero-user" class="size-5" /> {@current_scope.user.email}
|
||||
</li>
|
||||
<li>
|
||||
<details class="admin-dev-tools">
|
||||
<summary>
|
||||
|
||||
63
lib/berrypod_web/controllers/account_controller.ex
Normal file
63
lib/berrypod_web/controllers/account_controller.ex
Normal 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
|
||||
@ -4,7 +4,7 @@ defmodule BerrypodWeb.UserSessionController do
|
||||
alias Berrypod.Accounts
|
||||
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
|
||||
create(conn, params, "User confirmed successfully.")
|
||||
@ -15,14 +15,14 @@ defmodule BerrypodWeb.UserSessionController do
|
||||
end
|
||||
|
||||
# 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
|
||||
{:ok, {user, tokens_to_disconnect}} ->
|
||||
UserAuth.disconnect_sessions(tokens_to_disconnect)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
|> maybe_store_return_to(params)
|
||||
|> maybe_require_totp(user, user_params, info)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
@ -32,13 +32,13 @@ defmodule BerrypodWeb.UserSessionController do
|
||||
end
|
||||
|
||||
# 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
|
||||
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
|> maybe_store_return_to(params)
|
||||
|> maybe_require_totp(user, user_params, info)
|
||||
else
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
@ -48,6 +48,55 @@ defmodule BerrypodWeb.UserSessionController do
|
||||
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
|
||||
user = conn.assigns.current_scope.user
|
||||
true = Accounts.sudo_mode?(user)
|
||||
@ -57,7 +106,7 @@ defmodule BerrypodWeb.UserSessionController do
|
||||
UserAuth.disconnect_sessions(expired_tokens)
|
||||
|
||||
conn
|
||||
|> put_session(:user_return_to, ~p"/admin/settings")
|
||||
|> put_session(:user_return_to, ~p"/admin/account")
|
||||
|> create(params, "Password updated successfully!")
|
||||
end
|
||||
|
||||
|
||||
466
lib/berrypod_web/live/admin/account.ex
Normal file
466
lib/berrypod_web/live/admin/account.ex
Normal 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
|
||||
@ -1,7 +1,6 @@
|
||||
defmodule BerrypodWeb.Admin.Settings do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Accounts
|
||||
alias Berrypod.Products
|
||||
alias Berrypod.Settings
|
||||
alias Berrypod.Stripe.Setup, as: StripeSetup
|
||||
@ -19,8 +18,7 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
|> assign(:from_address_status, :idle)
|
||||
|> assign(:signing_secret_status, :idle)
|
||||
|> assign_stripe_state()
|
||||
|> assign_products_state()
|
||||
|> assign_account_state(user)}
|
||||
|> assign_products_state()}
|
||||
end
|
||||
|
||||
# -- Stripe assigns --
|
||||
@ -66,19 +64,6 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
assign(socket, :provider, connection_info)
|
||||
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 --
|
||||
|
||||
@impl true
|
||||
@ -232,73 +217,6 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
|> put_flash(:info, "Provider connection deleted")}
|
||||
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 --
|
||||
|
||||
@impl true
|
||||
@ -470,81 +388,6 @@ defmodule BerrypodWeb.Admin.Settings do
|
||||
</form>
|
||||
</div>
|
||||
</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>
|
||||
"""
|
||||
end
|
||||
|
||||
@ -39,6 +39,7 @@ defmodule BerrypodWeb.Auth.Login do
|
||||
action={~p"/users/log-in"}
|
||||
phx-submit="submit_magic"
|
||||
>
|
||||
<input :if={@return_to} type="hidden" name="return_to" value={@return_to} />
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
@ -64,6 +65,7 @@ defmodule BerrypodWeb.Auth.Login do
|
||||
phx-submit="submit_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input :if={@return_to} type="hidden" name="return_to" value={@return_to} />
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
@ -103,18 +105,20 @@ defmodule BerrypodWeb.Auth.Login do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
def mount(params, _session, socket) do
|
||||
email =
|
||||
Phoenix.Flash.get(socket.assigns.flash, :email) ||
|
||||
get_in(socket.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
|
||||
|
||||
form = to_form(%{"email" => email}, as: "user")
|
||||
return_to = params["return_to"]
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
form: form,
|
||||
trigger_submit: false,
|
||||
email_configured: Mailer.email_verified?()
|
||||
email_configured: Mailer.email_verified?(),
|
||||
return_to: return_to
|
||||
)}
|
||||
end
|
||||
|
||||
|
||||
90
lib/berrypod_web/live/auth/totp_verification.ex
Normal file
90
lib/berrypod_web/live/auth/totp_verification.ex
Normal 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">→</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
|
||||
@ -143,6 +143,11 @@ defmodule BerrypodWeb.Router do
|
||||
post "/settings/email/test", EmailSettingsController, :test
|
||||
post "/settings/from-address", SettingsController, :update_from_address
|
||||
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 "/providers", ProvidersController, :create
|
||||
post "/providers/:id", ProvidersController, :update
|
||||
@ -165,6 +170,7 @@ defmodule BerrypodWeb.Router do
|
||||
live "/providers/:id/edit", Admin.Providers.Form, :edit
|
||||
live "/settings", Admin.Settings, :index
|
||||
live "/settings/email", Admin.EmailSettings, :index
|
||||
live "/account", Admin.Account, :index
|
||||
live "/pages", Admin.Pages.Index, :index
|
||||
live "/pages/new", Admin.Pages.CustomForm, :new
|
||||
live "/pages/:slug/settings", Admin.Pages.CustomForm, :edit
|
||||
@ -208,9 +214,11 @@ defmodule BerrypodWeb.Router do
|
||||
live "/users/register", Auth.Registration, :new
|
||||
live "/users/log-in", Auth.Login, :new
|
||||
live "/users/log-in/:token", Auth.Confirmation, :new
|
||||
live "/users/totp", Auth.TotpVerification, :new
|
||||
end
|
||||
|
||||
post "/users/log-in", UserSessionController, :create
|
||||
post "/users/verify-totp", UserSessionController, :verify_totp
|
||||
delete "/users/log-out", UserSessionController, :delete
|
||||
end
|
||||
|
||||
|
||||
4
mix.exs
4
mix.exs
@ -81,7 +81,9 @@ defmodule Berrypod.MixProject do
|
||||
{:logger_json, "~> 7.0", only: :prod},
|
||||
{:credo, "~> 1.7", 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
|
||||
|
||||
|
||||
2
mix.lock
2
mix.lock
@ -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_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"},
|
||||
"eqrcode": {:hex, :eqrcode, "0.2.1", "d12838813e8fc87b8940cc05f9baadb189031f6009facdc56ff074375ec73b6e", [:mix], [], "hexpm", "d5828a222b904c68360e7dc2a40c3ef33a1328b7c074583898040f389f928025"},
|
||||
"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"},
|
||||
"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_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||
"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_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"},
|
||||
|
||||
14
priv/repo/migrations/20260308085927_add_totp_to_users.exs
Normal file
14
priv/repo/migrations/20260308085927_add_totp_to_users.exs
Normal 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
|
||||
@ -466,4 +466,104 @@ defmodule Berrypod.AccountsTest do
|
||||
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
|
||||
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
|
||||
|
||||
101
test/berrypod/rate_limit_test.exs
Normal file
101
test/berrypod/rate_limit_test.exs
Normal 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
|
||||
123
test/berrypod_web/live/admin/account_test.exs
Normal file
123
test/berrypod_web/live/admin/account_test.exs
Normal 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
|
||||
@ -37,12 +37,6 @@ defmodule BerrypodWeb.Admin.LayoutTest do
|
||||
refute has_element?(view, ~s(a.active[href="/admin/orders"]))
|
||||
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
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/orders")
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ defmodule BerrypodWeb.Admin.SettingsTest do
|
||||
import Berrypod.AccountsFixtures
|
||||
import Berrypod.ProductsFixtures
|
||||
|
||||
alias Berrypod.Accounts
|
||||
alias Berrypod.Settings
|
||||
|
||||
setup do
|
||||
@ -166,80 +165,6 @@ defmodule BerrypodWeb.Admin.SettingsTest do
|
||||
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
|
||||
setup %{conn: conn, user: user} do
|
||||
conn = log_in_user(conn, user)
|
||||
@ -256,7 +181,6 @@ defmodule BerrypodWeb.Admin.SettingsTest do
|
||||
test "saves from address", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/settings")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("form[phx-submit=\"save_from_address\"]", %{from_address: "shop@example.com"})
|
||||
|> render_submit()
|
||||
@ -265,18 +189,4 @@ defmodule BerrypodWeb.Admin.SettingsTest do
|
||||
assert Settings.get_setting("email_from_address") == "shop@example.com"
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user