From d33a75d4acf239550ef90eab28305f09da8b52a8 Mon Sep 17 00:00:00 2001 From: Ali-Nawaz-devt Date: Mon, 26 Oct 2026 07:33:33 +0500 Subject: [PATCH] feat(settings): redesign account settings frontend with one-section layout --- static/css/settings.css | 1272 ++++++++++++++++++-------- static/img/icons/settings-sprite.svg | 29 + static/js/settings.js | 479 +++++++--- templates/accounts/settings.html | 1160 ++++++++++++----------- tests/unit/test_settings_redesign.py | 312 +++++++ 5 files changed, 2210 insertions(+), 1042 deletions(-) create mode 100644 static/img/icons/settings-sprite.svg create mode 100644 tests/unit/test_settings_redesign.py diff --git a/static/css/settings.css b/static/css/settings.css index 735714c..e6c1e60 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -1,546 +1,1010 @@ -/* Passli Account Settings Scoped Stylesheet */ +/* Passli Account Settings Scoped Stylesheet — Zero-Slop Architecture */ + +:root { + /* Spacing Tokens */ + --s-space-2: 2px; + --s-space-4: 4px; + --s-space-6: 6px; + --s-space-8: 8px; + --s-space-10: 10px; + --s-space-12: 12px; + --s-space-14: 14px; + --s-space-16: 16px; + --s-space-20: 20px; + --s-space-24: 24px; + --s-space-28: 28px; + --s-space-32: 32px; + --s-space-40: 40px; + --s-space-44: 44px; + --s-space-48: 48px; + --s-space-56: 56px; + --s-space-64: 64px; + + /* Typography Scale */ + --s-font-12: 12px; + --s-font-13: 13px; + --s-font-14: 14px; + --s-font-15: 15px; + --s-font-16: 16px; + --s-font-18: 18px; + --s-font-20: 20px; + --s-font-22: 22px; + --s-font-24: 24px; + --s-font-28: 28px; + + /* Semantic Colors */ + --s-color-bg: #f8fafc; + --s-color-surface: #ffffff; + --s-color-subtle: #f1f5f9; + --s-color-text: #0f172a; + --s-color-secondary: #334155; + --s-color-muted: #64748b; + --s-color-border: #e2e8f0; + --s-color-input-border: #cbd5e1; + --s-color-navy: #0f172a; + --s-color-navy-hover: #1e293b; + --s-color-navy-focus: rgba(15, 23, 42, 0.1); + --s-color-white: #ffffff; + + --s-color-emerald: #047857; + --s-color-emerald-bg: #f0fdf4; + --s-color-emerald-border: #a7f3d0; + + --s-color-warning-text: #b45309; + --s-color-warning-bg: #fffbeb; + --s-color-warning-border: #fde68a; + + --s-color-danger: #b91c1c; + --s-color-danger-bg: #fef2f2; + --s-color-danger-border: #fecaca; + --s-color-backdrop: rgba(15, 23, 42, 0.5); + + /* Fonts & Weights */ + --s-font-ui: var(--font-sans, 'Geist', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); + --s-font-mono: var(--font-mono, 'JetBrains Mono', ui-monospace, monospace); + --s-weight-regular: 400; + --s-weight-medium: 500; + --s-weight-semibold: 600; + + /* Geometry & Shadows */ + --s-radius-sm: 6px; + --s-radius-md: 8px; + --s-radius-lg: 12px; + --s-radius-card: 16px; + --s-radius-sheet: 20px; + --s-radius-pill: 9999px; + --s-shadow-card: 0 1px 2px rgba(15, 23, 42, 0.05); + --s-shadow-sheet: 0 -4px 24px rgba(15, 23, 42, 0.15); + --s-shadow-dialog: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + + /* Transitions */ + --s-duration: 150ms; + --s-easing: ease; +} + +/* Reduced Motion Respect */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} +/* Container & Page Header */ .settings-container { - max-width: 940px; - margin: 0 auto; - padding: 2.25rem 1rem 4rem; + max-width: 1040px; + margin: 0 auto; + padding: var(--s-space-48) var(--s-space-32) var(--s-space-64); + font-family: var(--s-font-ui); + color: var(--s-color-text); + box-sizing: border-box; } -.settings-page-header { - margin-bottom: 2rem; +.settings-header { + margin-bottom: var(--s-space-32); } -.settings-page-title { - font-size: 1.75rem; - font-weight: 600; - color: var(--text-main); - letter-spacing: -0.02em; - margin: 0 0 0.35rem 0; +.settings-title { + font-size: var(--s-font-28); + font-weight: var(--s-weight-semibold); + letter-spacing: -0.02em; + color: var(--s-color-text); + margin: 0 0 var(--s-space-8) 0; + line-height: 1.2; } -.settings-page-desc { - color: var(--text-muted); - font-size: 0.925rem; - margin: 0; +.settings-subtitle { + font-size: var(--s-font-15); + font-weight: var(--s-weight-regular); + color: var(--s-color-muted); + margin: 0; + line-height: 1.5; } -/* Two-column Layout on Desktop */ +/* Main Grid Layout */ .settings-layout { - display: grid; - grid-template-columns: 220px 1fr; - gap: 2rem; - align-items: start; + display: grid; + grid-template-columns: 200px 1fr; + gap: var(--s-space-48); + align-items: start; } -/* Sticky Navigation Sidebar */ -.settings-sidebar { - position: sticky; - top: 5rem; +/* Desktop Sidebar Menu */ +.settings-menu { + display: flex; + flex-direction: column; + gap: var(--s-space-4); + position: sticky; + top: 5rem; } -.settings-nav { - display: flex; - flex-direction: column; - gap: 0.35rem; - background: var(--bg-surface); - border: 1px solid var(--border-light); - border-radius: 12px; - padding: 0.5rem; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +.settings-menu-item { + display: flex; + align-items: center; + gap: var(--s-space-12); + padding: var(--s-space-10) var(--s-space-12); + border-radius: var(--s-radius-md); + font-size: var(--s-font-14); + font-weight: var(--s-weight-medium); + color: var(--s-color-secondary); + text-decoration: none; + background: transparent; + border: none; + width: 100%; + text-align: left; + cursor: pointer; + box-sizing: border-box; + min-height: var(--s-space-44); + transition: background-color var(--s-duration) var(--s-easing), color var(--s-duration) var(--s-easing); } -.settings-nav-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.65rem 0.85rem; - font-size: 0.9rem; - font-weight: 500; - color: var(--text-secondary); - border-radius: 8px; - text-decoration: none; - transition: background-color 150ms ease, color 150ms ease; +.settings-menu-item:hover { + background-color: var(--s-color-subtle); + color: var(--s-color-text); } -.settings-nav-item .material-symbols-outlined { - font-size: 1.15rem; - color: var(--text-muted); - transition: color 150ms ease; +.settings-menu-item.active, +.settings-menu-item[aria-current="page"] { + background-color: var(--s-color-subtle); + color: var(--s-color-text); + font-weight: var(--s-weight-semibold); } -.settings-nav-item:hover { - background-color: var(--bg-subtle); - color: var(--text-main); +.settings-menu-item:focus-visible { + outline: 2px solid var(--s-color-navy); + outline-offset: 2px; } -.settings-nav-item:hover .material-symbols-outlined { - color: var(--text-main); +/* Mobile Sticky Tabs (Hidden on Desktop) */ +.settings-tab-bar-wrapper { + display: none; } -.settings-nav-item.active { - background-color: #0f172a; - color: #ffffff; +/* Icons */ +.settings-icon { + width: 18px; + height: 18px; + display: inline-block; + flex-shrink: 0; + stroke: currentColor; } -.settings-nav-item.active .material-symbols-outlined { - color: #ffffff; +.settings-icon-sm { + width: 14px; + height: 14px; + display: inline-block; + flex-shrink: 0; } -.settings-nav-item:focus-visible { - outline: 2px solid var(--brand-blue); - outline-offset: 2px; +/* Progressive Enhancement: Section Visibility */ +.settings-section { + display: block; } -/* Main Content Column */ -.settings-content { - display: flex; - flex-direction: column; - gap: 1.75rem; +/* When JavaScript is active, show only ONE section at a time */ +.js-ready .settings-section:not(.active) { + display: none !important; } -/* Card Sections */ +/* Card Primitive */ .settings-card { - background: var(--bg-surface); - border: 1px solid var(--border-light); - border-radius: 12px; - padding: 1.75rem; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); - scroll-margin-top: 5.5rem; + background-color: var(--s-color-surface); + border: 1px solid var(--s-color-border); + border-radius: var(--s-radius-card); + box-shadow: var(--s-shadow-card); + overflow: hidden; + box-sizing: border-box; } .settings-card-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - padding-bottom: 1.25rem; - margin-bottom: 1.5rem; - border-bottom: 1px solid var(--border-light); - gap: 1rem; - flex-wrap: wrap; + padding: var(--s-space-24) var(--s-space-28) var(--s-space-20); + border-bottom: 1px solid var(--s-color-border); } .settings-card-title { - font-size: 1.15rem; - font-weight: 600; - color: var(--text-main); - margin: 0 0 0.25rem 0; - letter-spacing: -0.01em; + font-size: var(--s-font-18); + font-weight: var(--s-weight-semibold); + color: var(--s-color-text); + margin: 0 0 var(--s-space-4) 0; + line-height: 1.3; } .settings-card-desc { - color: var(--text-muted); - font-size: 0.85rem; - margin: 0; - line-height: 1.5; + font-size: var(--s-font-14); + color: var(--s-color-muted); + margin: 0; + line-height: 1.5; +} + +/* Row Primitive */ +.settings-row { + display: flex; + align-items: center; + gap: var(--s-space-24); + padding: var(--s-space-20) var(--s-space-28); + border-bottom: 1px solid var(--s-color-border); + box-sizing: border-box; + position: relative; + transition: background-color var(--s-duration) var(--s-easing); +} + +.settings-row:last-child { + border-bottom: none; +} + +.settings-row-label { + flex: 0 0 180px; + font-size: var(--s-font-14); + font-weight: var(--s-weight-medium); + color: var(--s-color-text); +} + +.settings-row-value { + flex: 1 1 auto; + font-size: var(--s-font-15); + color: var(--s-color-text); + min-width: 0; + word-break: break-word; +} + +.settings-row-value.font-mono-data { + font-family: var(--s-font-mono); + font-size: var(--s-font-14); +} + +.settings-row-action { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: var(--s-space-8); + margin-left: auto; +} + +.settings-row-chevron { + display: none; +} + +/* Badges & Status Pills */ +.settings-pill { + display: inline-flex; + align-items: center; + gap: var(--s-space-4); + padding: var(--s-space-2) var(--s-space-8); + font-size: var(--s-font-12); + font-weight: var(--s-weight-semibold); + letter-spacing: 0.04em; + text-transform: uppercase; + border-radius: var(--s-radius-pill); + line-height: 1.4; + vertical-align: middle; +} + +.settings-pill-success { + background-color: var(--s-color-emerald-bg); + color: var(--s-color-emerald); + border: 1px solid var(--s-color-emerald-border); +} + +.settings-pill-warning { + background-color: var(--s-color-warning-bg); + color: var(--s-color-warning-text); + border: 1px solid var(--s-color-warning-border); +} + +.settings-pill-muted { + background-color: var(--s-color-subtle); + color: var(--s-color-muted); + border: 1px solid var(--s-color-border); +} + +/* Amber Cooldown / Lock Text */ +.settings-notice-amber { + display: inline-flex; + align-items: center; + gap: var(--s-space-6); + font-size: var(--s-font-13); + color: var(--s-color-warning-text); + background-color: var(--s-color-warning-bg); + border: 1px solid var(--s-color-warning-border); + border-radius: var(--s-radius-md); + padding: var(--s-space-8) var(--s-space-12); +} + +.settings-notice-danger { + display: inline-flex; + align-items: center; + gap: var(--s-space-6); + font-size: var(--s-font-13); + color: var(--s-color-danger); + background-color: var(--s-color-danger-bg); + border: 1px solid var(--s-color-danger-border); + border-radius: var(--s-radius-md); + padding: var(--s-space-8) var(--s-space-12); +} + +/* Buttons */ +.settings-btn-secondary { + height: 36px; + min-height: 36px; + padding: 0 var(--s-space-14); + border: 1px solid var(--s-color-input-border); + border-radius: var(--s-radius-md); + background-color: var(--s-color-surface); + color: var(--s-color-text); + font-size: var(--s-font-14); + font-weight: var(--s-weight-medium); + font-family: var(--s-font-ui); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s-space-6); + cursor: pointer; + text-decoration: none; + box-sizing: border-box; + transition: background-color var(--s-duration) var(--s-easing), border-color var(--s-duration) var(--s-easing); +} + +.settings-btn-secondary:hover { + background-color: var(--s-color-subtle); + border-color: var(--s-color-muted); +} + +.settings-btn-secondary:focus-visible { + outline: 2px solid var(--s-color-navy); + outline-offset: 2px; +} + +.settings-btn-primary { + height: 44px; + min-height: 44px; + padding: 0 var(--s-space-20); + border: 1px solid var(--s-color-navy); + border-radius: var(--s-radius-md); + background-color: var(--s-color-navy); + color: var(--s-color-white); + font-size: var(--s-font-14); + font-weight: var(--s-weight-medium); + font-family: var(--s-font-ui); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s-space-6); + cursor: pointer; + text-decoration: none; + box-sizing: border-box; + transition: background-color var(--s-duration) var(--s-easing); +} + +.settings-btn-primary:hover { + background-color: var(--s-color-navy-hover); +} + +.settings-btn-primary:focus-visible { + outline: 2px solid var(--s-color-navy); + outline-offset: 2px; +} + +.settings-btn-danger-text { + background: transparent; + border: none; + color: var(--s-color-danger); + font-size: var(--s-font-14); + font-weight: var(--s-weight-medium); + font-family: var(--s-font-ui); + cursor: pointer; + padding: var(--s-space-8) var(--s-space-12); + border-radius: var(--s-radius-md); + display: inline-flex; + align-items: center; + gap: var(--s-space-4); + text-decoration: none; +} + +.settings-btn-danger-text:hover { + background-color: var(--s-color-danger-bg); +} + +.settings-btn-text-cancel { + background: transparent; + border: none; + color: var(--s-color-muted); + font-size: var(--s-font-14); + font-weight: var(--s-weight-medium); + font-family: var(--s-font-ui); + cursor: pointer; + padding: var(--s-space-8) var(--s-space-12); + border-radius: var(--s-radius-md); +} + +.settings-btn-text-cancel:hover { + color: var(--s-color-text); + background-color: var(--s-color-subtle); +} + +/* Open Panel Primitive (Desktop / Inline) */ +.settings-panel { + background-color: var(--s-color-bg); + border-top: 1px solid var(--s-color-border); + padding: var(--s-space-24) var(--s-space-28) var(--s-space-28); + box-sizing: border-box; +} + +/* Progressive Enhancement: Panels with hidden attribute are hidden in JS */ +.js-ready .settings-panel[hidden] { + display: none !important; +} + +.settings-panel-form { + max-width: 420px; } -/* Form Layout */ +/* Form Groups & Inputs */ .settings-form-group { - margin-bottom: 1.35rem; + margin-bottom: var(--s-space-20); } .settings-form-label { - display: block; - font-size: 0.85rem; - font-weight: 500; - color: var(--text-main); - margin-bottom: 0.45rem; + display: block; + font-size: var(--s-font-14); + font-weight: var(--s-weight-medium); + color: var(--s-color-text); + margin-bottom: var(--s-space-6); } -.settings-form-hint { - display: block; - font-size: 0.8rem; - color: var(--text-muted); - margin-top: 0.35rem; - line-height: 1.4; +.settings-input { + width: 100%; + height: 44px; + padding: 0 var(--s-space-14); + border: 1px solid var(--s-color-input-border); + border-radius: var(--s-radius-md); + font-size: var(--s-font-15); + font-family: var(--s-font-ui); + color: var(--s-color-text); + background-color: var(--s-color-surface); + box-sizing: border-box; + transition: border-color var(--s-duration) var(--s-easing), box-shadow var(--s-duration) var(--s-easing); } -.settings-form-error { - font-size: 0.825rem; - color: #b91c1c; - margin-top: 0.35rem; +.settings-input:focus { + outline: none; + border-color: var(--s-color-navy); + box-shadow: 0 0 0 3px var(--s-color-navy-focus); } -.settings-card-actions { - display: flex; - justify-content: flex-end; - align-items: center; - gap: 0.75rem; - margin-top: 1.75rem; - padding-top: 1.25rem; - border-top: 1px solid var(--border-light); - flex-wrap: wrap; +.settings-hint { + display: block; + font-size: var(--s-font-13); + color: var(--s-color-muted); + margin-top: var(--s-space-6); + line-height: 1.45; } -/* Avatar Component */ -.settings-avatar-row { - display: flex; - align-items: center; - gap: 1.5rem; - flex-wrap: wrap; +.settings-error { + display: block; + font-size: var(--s-font-13); + color: var(--s-color-danger); + margin-top: var(--s-space-6); + line-height: 1.45; } -.settings-avatar-preview-wrapper { - position: relative; - width: 80px; - height: 80px; - border-radius: 50%; - overflow: hidden; - background-color: #0f172a; - display: flex; - align-items: center; - justify-content: center; - border: 2px solid var(--border-light); - flex-shrink: 0; +/* Password Toggle Wrapper */ +.settings-password-wrapper { + position: relative; + display: flex; + align-items: center; } -.settings-avatar-img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; +.settings-password-wrapper .settings-input { + padding-right: var(--s-space-44); } -.settings-avatar-fallback { - font-size: 2rem; - font-weight: 600; - color: #ffffff; - font-family: 'JetBrains Mono', monospace; - line-height: 1; +.settings-password-toggle-btn { + position: absolute; + right: var(--s-space-8); + background: transparent; + border: none; + color: var(--s-color-muted); + cursor: pointer; + padding: var(--s-space-6); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--s-radius-sm); + min-width: 32px; + min-height: 32px; } -.settings-avatar-controls { - display: flex; - flex-direction: column; - gap: 0.5rem; +.settings-password-toggle-btn:hover { + color: var(--s-color-text); } -.settings-avatar-file-input { - display: none; +/* Password Strength Meter */ +.settings-strength-meter { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--s-space-4); + margin-top: var(--s-space-8); } -/* Status Badges */ -.settings-badge { - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-size: 0.75rem; - font-weight: 600; - padding: 0.25rem 0.65rem; - border-radius: 9999px; - font-family: 'JetBrains Mono', monospace; - letter-spacing: 0.04em; - text-transform: uppercase; +.settings-strength-segment { + height: 4px; + border-radius: 2px; + background-color: var(--s-color-border); + transition: background-color var(--s-duration) var(--s-easing); } -.settings-badge-success { - background-color: #ecfdf5; - color: #047857; - border: 1px solid #a7f3d0; +.settings-strength-segment.active-weak { + background-color: var(--s-color-danger); } -.settings-badge-warning { - background-color: #fffbeb; - color: #b45309; - border: 1px solid #fde68a; +.settings-strength-segment.active-fair { + background-color: var(--s-color-warning-text); } -.settings-badge-muted { - background-color: var(--bg-subtle); - color: var(--text-muted); - border: 1px solid var(--border-light); +.settings-strength-segment.active-good { + background-color: var(--s-color-emerald); } -/* Activity Audit Table */ -.settings-activity-table { - width: 100%; - border-collapse: collapse; - font-size: 0.875rem; +.settings-strength-segment.active-strong { + background-color: var(--s-color-emerald); } -.settings-activity-table th { - text-align: left; - padding: 0.75rem 0.5rem; - font-size: 0.725rem; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 1px solid var(--border-light); - font-family: 'JetBrains Mono', monospace; +/* Panel Actions Footer */ +.settings-panel-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--s-space-12); + margin-top: var(--s-space-24); } -.settings-activity-table td { - padding: 0.85rem 0.5rem; - border-bottom: 1px solid var(--border-light); - color: var(--text-secondary); - vertical-align: middle; +/* Pending Flows: Step 2 of 2 Views */ +.settings-step-eyebrow { + font-family: var(--s-font-mono); + font-size: var(--s-font-12); + font-weight: var(--s-weight-semibold); + letter-spacing: 0.08em; + color: var(--s-color-muted); + text-transform: uppercase; + margin-bottom: var(--s-space-4); } -.settings-activity-table tr:last-child td { - border-bottom: none; +.settings-step-title { + font-size: var(--s-font-18); + font-weight: var(--s-weight-semibold); + color: var(--s-color-text); + margin: 0 0 var(--s-space-8) 0; } -.settings-activity-event { - font-weight: 500; - color: var(--text-main); +.settings-step-dest { + font-family: var(--s-font-mono); + font-size: var(--s-font-14); + color: var(--s-color-text); + font-weight: var(--s-weight-medium); + background-color: var(--s-color-subtle); + padding: var(--s-space-4) var(--s-space-8); + border-radius: var(--s-radius-sm); + display: inline-block; + margin-bottom: var(--s-space-16); } -.settings-activity-meta { - font-size: 0.8rem; - color: var(--text-muted); - font-family: 'JetBrains Mono', monospace; +/* 6-Cell OTP Grid */ +.settings-otp-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: var(--s-space-8); + margin-bottom: var(--s-space-12); + max-width: 360px; } -.settings-empty-state { - text-align: center; - padding: 2.5rem 1rem; - color: var(--text-muted); - font-size: 0.9rem; -} - -/* Current value display banner */ -.settings-current-value { - background-color: var(--bg-subtle); - border: 1px solid var(--border-light); - border-radius: 8px; - padding: 0.75rem 1rem; - margin-bottom: 1.25rem; - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - flex-wrap: wrap; -} - -.settings-current-label { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - font-family: 'JetBrains Mono', monospace; - letter-spacing: 0.05em; - margin-bottom: 0.2rem; -} - -.settings-current-data { - font-size: 0.95rem; - font-weight: 600; - color: var(--text-main); -} - -/* Danger Button */ -.btn-danger-outline { - background: transparent; - color: #b91c1c; - border: 1px solid #fca5a5; - padding: 0.45rem 0.85rem; - font-size: 0.85rem; - border-radius: 6px; - cursor: pointer; - transition: background-color 150ms ease, color 150ms ease; - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-weight: 500; +.settings-otp-box { + height: 56px; + width: 100%; + text-align: center; + font-family: var(--s-font-mono); + font-size: var(--s-font-20); + font-weight: var(--s-weight-semibold); + color: var(--s-color-text); + background-color: var(--s-color-surface); + border: 1px solid var(--s-color-input-border); + border-radius: var(--s-radius-md); + box-sizing: border-box; + padding: 0; + transition: border-color var(--s-duration) var(--s-easing), box-shadow var(--s-duration) var(--s-easing); } -.btn-danger-outline:hover { - background-color: #fef2f2; - border-color: #f87171; +.settings-otp-box:focus { + outline: none; + border-color: var(--s-color-navy); + box-shadow: 0 0 0 3px var(--s-color-navy-focus); } -.btn-danger-outline:focus-visible { - outline: 2px solid #b91c1c; - outline-offset: 2px; +.settings-otp-fallback { + display: none; } -/* Pending Alert Banner */ -.settings-pending-alert { - background-color: var(--bg-subtle); - border: 1px solid var(--border-light); - border-radius: 8px; - padding: 1rem; - margin-bottom: 1.25rem; - display: flex; - align-items: flex-start; - gap: 0.75rem; +/* Avatar Row Elements */ +.settings-avatar-wrap { + width: 56px; + height: 56px; + border-radius: var(--s-radius-pill); + background-color: var(--s-color-subtle); + border: 1px solid var(--s-color-border); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; } -.settings-pending-alert-icon { - color: var(--brand-blue); - display: flex; - align-items: center; - padding-top: 0.1rem; +.settings-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; } -/* 6-Digit OTP Box UI & Progressive Enhancement */ -.settings-otp-wrapper { - display: none; - gap: 8px; - margin: 0.5rem 0 0.5rem; - align-items: center; +.settings-avatar-initial { + font-family: var(--s-font-mono); + font-size: var(--s-font-20); + font-weight: var(--s-weight-semibold); + color: var(--s-color-text); } -.js-ready .settings-otp-wrapper { - display: flex; +.settings-card-footnote { + padding: var(--s-space-16) var(--s-space-28); + font-size: var(--s-font-13); + color: var(--s-color-muted); + border-top: 1px solid var(--s-color-border); + background-color: var(--s-color-surface); + line-height: 1.5; } -.js-ready .settings-otp-fallback { - display: none; +/* Activity Section Table */ +.settings-activity-table-wrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; } -.settings-otp-box { - width: 48px; - height: 54px; - font-family: 'JetBrains Mono', monospace; - font-size: 1.5rem; - font-weight: 600; - text-align: center; - border: 1.5px solid var(--border-light); - border-radius: 8px; - background: var(--bg-surface); - color: var(--text-main); - outline: none; - transition: border-color 150ms ease, box-shadow 150ms ease; - -webkit-appearance: none; - margin: 0; +.settings-activity-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: var(--s-font-14); } -.settings-otp-box:focus { - border-color: var(--brand-navy); - box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.1); +.settings-activity-table th { + padding: var(--s-space-12) var(--s-space-28); + font-size: var(--s-font-12); + font-weight: var(--s-weight-medium); + color: var(--s-color-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--s-color-border); + background-color: var(--s-color-bg); } -.settings-otp-box.filled { - border-color: #94a3b8; +.settings-activity-table td { + padding: var(--s-space-16) var(--s-space-28); + border-bottom: 1px solid var(--s-color-border); + color: var(--s-color-secondary); } -/* Password Input & Strength Meter */ -.settings-password-field-wrapper { - position: relative; - display: flex; - align-items: center; - width: 100%; +.settings-activity-table tr:last-child td { + border-bottom: none; } -.settings-password-field-wrapper .form-input { - padding-right: 2.75rem; +.settings-activity-event { + font-weight: var(--s-weight-medium); + color: var(--s-color-text); } -.settings-password-toggle { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - background: transparent; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 6px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - transition: color 150ms ease, background-color 150ms ease; +.settings-activity-meta { + font-family: var(--s-font-mono); + font-size: var(--s-font-13); + color: var(--s-color-muted); } -.settings-password-toggle:hover { - color: var(--text-main); - background-color: var(--bg-subtle); +.settings-activity-mobile-list { + display: none; } -.settings-password-toggle:focus-visible { - outline: 2px solid var(--brand-blue); - outline-offset: 1px; +.settings-empty-state { + padding: var(--s-space-48) var(--s-space-28); + text-align: center; + color: var(--s-color-muted); + font-size: var(--s-font-14); } -.settings-strength-meter { - height: 4px; - border-radius: 2px; - background-color: var(--border-light); - margin-top: 6px; - overflow: hidden; - width: 100%; +/* Native Dialog for Photo Removal */ +.settings-dialog { + border: 1px solid var(--s-color-border); + border-radius: var(--s-radius-card); + padding: var(--s-space-28); + background-color: var(--s-color-surface); + color: var(--s-color-text); + max-width: 440px; + width: 90vw; + box-shadow: var(--s-shadow-dialog); + box-sizing: border-box; } -.settings-strength-bar { - height: 100%; - width: 0%; - border-radius: 2px; - transition: width 200ms ease, background-color 200ms ease; +.settings-dialog::backdrop { + background-color: var(--s-color-backdrop); + backdrop-filter: blur(2px); } -.settings-strength-bar.weak { - width: 25%; - background-color: #ef4444; +.settings-dialog-title { + font-size: var(--s-font-18); + font-weight: var(--s-weight-semibold); + margin: 0 0 var(--s-space-8) 0; } -.settings-strength-bar.fair { - width: 50%; - background-color: #f59e0b; +.settings-dialog-desc { + font-size: var(--s-font-14); + color: var(--s-color-muted); + margin: 0 0 var(--s-space-24) 0; + line-height: 1.5; } -.settings-strength-bar.good { - width: 75%; - background-color: #3b82f6; +.settings-dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--s-space-12); } -.settings-strength-bar.strong { - width: 100%; - background-color: #10b981; -} +/* Mobile Responsiveness (Under 960px) */ +@media (max-width: 959px) { + .settings-container { + padding: var(--s-space-24) var(--s-space-16) var(--s-space-48); + } -.settings-strength-hint { - font-size: 0.75rem; - color: var(--text-muted); - margin-top: 4px; + .settings-title { + font-size: var(--s-font-24); + } + + .settings-layout { display: block; -} + } + + .settings-menu { + display: none; + } + + /* Sticky Scrollable Pill Tabs */ + .settings-tab-bar-wrapper { + display: block; + position: sticky; + top: var(--s-space-56); + z-index: 40; + background-color: var(--s-color-bg); + padding: var(--s-space-8) 0 var(--s-space-16); + margin-bottom: var(--s-space-16); + } + + .settings-tab-bar { + display: flex; + gap: var(--s-space-8); + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + padding: var(--s-space-2) var(--s-space-4); + } + + .settings-tab-bar::-webkit-scrollbar { + display: none; + } + + .settings-tab-pill { + height: 44px; + min-height: 44px; + padding: 0 var(--s-space-16); + border-radius: var(--s-radius-pill); + border: 1px solid var(--s-color-input-border); + background-color: var(--s-color-surface); + color: var(--s-color-secondary); + font-size: var(--s-font-14); + font-weight: var(--s-weight-medium); + font-family: var(--s-font-ui); + display: inline-flex; + align-items: center; + gap: var(--s-space-6); + white-space: nowrap; + text-decoration: none; + cursor: pointer; + flex-shrink: 0; + transition: background-color var(--s-duration) var(--s-easing), color var(--s-duration) var(--s-easing); + } + + .settings-tab-pill.active, + .settings-tab-pill[aria-current="page"] { + background-color: var(--s-color-navy); + color: var(--s-color-white); + border-color: var(--s-color-navy); + } + + /* Row on tablet/mobile: min-height 68 */ + .settings-row { + min-height: 68px; + padding: var(--s-space-16) var(--s-space-20); + gap: var(--s-space-16); + } + + .settings-row-label { + flex: 0 0 140px; + } +} + +/* Mobile Phone Bottom Sheet & Stacked Layout (Under 640px) */ +@media (max-width: 639px) { + .settings-container { + padding: var(--s-space-16) var(--s-space-12) var(--s-space-40); + } + + .settings-card { + border-radius: var(--s-radius-lg); + } + + .settings-card-header { + padding: var(--s-space-16) var(--s-space-16) var(--s-space-12); + } + + /* Mobile List Item Rows: stacked label above value */ + .settings-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: var(--s-space-14) var(--s-space-16); + gap: var(--s-space-12); + min-height: 68px; + cursor: pointer; + } + + .settings-row-content { + display: flex; + flex-direction: column; + gap: var(--s-space-2); + flex: 1 1 auto; + min-width: 0; + } + + .settings-row-label { + flex: none; + font-size: var(--s-font-13); + color: var(--s-color-muted); + } + + .settings-row-value { + font-size: var(--s-font-16); + color: var(--s-color-text); + } + + .settings-row-action { + margin-left: 0; + flex-shrink: 0; + } + + .settings-row-chevron { + display: inline-flex; + color: var(--s-color-muted); + } + + /* Bottom Sheet Transformation */ + .js-ready .settings-panel { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + background-color: var(--s-color-surface); + border-radius: var(--s-radius-sheet) var(--s-radius-sheet) 0 0; + border-top: none; + box-shadow: var(--s-shadow-sheet); + max-height: 85vh; + overflow-y: auto; + padding: var(--s-space-12) var(--s-space-20) max(var(--s-space-24), env(safe-area-inset-bottom)); + } + + .settings-sheet-drag-handle { + width: 36px; + height: 4px; + border-radius: 2px; + background-color: var(--s-color-input-border); + margin: var(--s-space-4) auto var(--s-space-16); + } + + .settings-sheet-backdrop { + display: none; + position: fixed; + inset: 0; + background-color: var(--s-color-backdrop); + z-index: 999; + } -/* Mobile Responsiveness */ -@media (max-width: 768px) { - .settings-layout { - grid-template-columns: 1fr; - gap: 1.25rem; - } + .js-ready body.settings-sheet-open { + overflow: hidden !important; + } - .settings-sidebar { - position: static; - } + .js-ready body.settings-sheet-open .settings-sheet-backdrop { + display: block; + } + + .settings-panel-actions { + flex-direction: column; + width: 100%; + gap: var(--s-space-8); + } - .settings-nav { - flex-direction: row; - overflow-x: auto; - padding: 0.35rem; - gap: 0.25rem; - -webkit-overflow-scrolling: touch; - } + .settings-panel-actions .settings-btn-primary { + width: 100%; + height: 48px; + min-height: 48px; + } - .settings-nav-item { - white-space: nowrap; - padding: 0.5rem 0.75rem; - font-size: 0.85rem; - } + .settings-panel-actions .settings-btn-secondary, + .settings-panel-actions .settings-btn-text-cancel { + width: 100%; + height: 44px; + text-align: center; + justify-content: center; + } - .settings-card { - padding: 1.25rem; - } + /* Activity Stacked Mobile Rows */ + .settings-activity-table-wrapper { + display: none; + } - .settings-otp-wrapper { - gap: 6px; - } + .settings-activity-mobile-list { + display: flex; + flex-direction: column; + } - .settings-otp-box { - width: 40px; - height: 48px; - font-size: 1.25rem; - } -} + .settings-activity-mobile-item { + padding: var(--s-space-14) var(--s-space-16); + border-bottom: 1px solid var(--s-color-border); + display: flex; + flex-direction: column; + gap: var(--s-space-4); + } -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - } + .settings-activity-mobile-item:last-child { + border-bottom: none; + } + + .settings-activity-mobile-title { + font-weight: var(--s-weight-medium); + font-size: var(--s-font-14); + color: var(--s-color-text); + } + + .settings-activity-mobile-meta { + font-family: var(--s-font-mono); + font-size: var(--s-font-12); + color: var(--s-color-muted); + } } diff --git a/static/img/icons/settings-sprite.svg b/static/img/icons/settings-sprite.svg new file mode 100644 index 0000000..165c287 --- /dev/null +++ b/static/img/icons/settings-sprite.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/js/settings.js b/static/js/settings.js index c4e11d6..552e8a8 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -1,59 +1,293 @@ +/** + * Passli Account Settings Module + * Vanilla ES module handling client-side section switching, hash routing, + * disclosure bottom sheet / inline panels, OTP input grid, and form controls. + */ + +// Step 1: Mark progressive enhancement readiness document.documentElement.classList.add('js-ready'); document.addEventListener('DOMContentLoaded', () => { - initNavigationTabs(); - initAvatarUploader(); + initSectionNavigation(); + initDisclosurePanels(); + initAvatarControls(); initOtpInputs(); - initResendTimer(); + initResendTimers(); initPasswordToggles(); initPasswordStrength(); + initRemoveAvatarDialog(); }); -function initNavigationTabs() { - const navItems = document.querySelectorAll('.settings-nav-item'); - if (!navItems.length) return; +/** + * 1. Section Navigation & Legacy Hash Mapping + * Maps: + * #profile -> Profile section + * #username -> Profile section (and opens username row) + * #security -> Security section + * #email -> Security section (and opens email row) + * #password -> Security section (and opens password row) + * #activity -> Activity section + */ +function initSectionNavigation() { + const sections = Array.from(document.querySelectorAll('.settings-section')); + const menuItems = Array.from(document.querySelectorAll('.settings-menu-item')); + const tabPills = Array.from(document.querySelectorAll('.settings-tab-pill')); + + if (!sections.length) return; + + function getTargetSectionAndPanel(hash) { + const clean = (hash || '').replace('#', '').toLowerCase(); + if (clean === 'username') { + return { sectionId: 'profile', panelId: 'panel-username' }; + } + if (clean === 'email') { + return { sectionId: 'security', panelId: 'panel-email' }; + } + if (clean === 'password') { + return { sectionId: 'security', panelId: 'panel-password' }; + } + if (clean === 'security') { + return { sectionId: 'security', panelId: null }; + } + if (clean === 'activity') { + return { sectionId: 'activity', panelId: null }; + } + return { sectionId: 'profile', panelId: null }; + } - function activateTab(targetId) { - if (!targetId) return; - const hash = targetId.startsWith('#') ? targetId : `#${targetId}`; - - navItems.forEach(item => { - const isMatch = item.getAttribute('href') === hash; + function switchSection(targetSectionId, options = {}) { + sections.forEach(section => { + const isMatch = section.id === targetSectionId; + section.classList.toggle('active', isMatch); + if (!isMatch && options.closePanels !== false) { + // Close any open panels in other sections + section.querySelectorAll('.settings-panel').forEach(p => { + p.setAttribute('hidden', ''); + }); + section.querySelectorAll('[aria-expanded="true"]').forEach(btn => { + btn.setAttribute('aria-expanded', 'false'); + }); + } + }); + + // Update Desktop Menu + menuItems.forEach(item => { + const href = (item.getAttribute('href') || '').replace('#', ''); + const isMatch = href === targetSectionId; item.classList.toggle('active', isMatch); - item.setAttribute('aria-current', isMatch ? 'true' : 'false'); + item.setAttribute('aria-current', isMatch ? 'page' : 'false'); }); + + // Update Mobile Tab Pills + tabPills.forEach(pill => { + const href = (pill.getAttribute('href') || '').replace('#', ''); + const isMatch = href === targetSectionId; + pill.classList.toggle('active', isMatch); + pill.setAttribute('aria-current', isMatch ? 'page' : 'false'); + if (isMatch) { + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + pill.scrollIntoView({ + behavior: prefersReducedMotion ? 'auto' : 'smooth', + inline: 'center', + block: 'nearest' + }); + } + }); + + if (options.updateHash !== false) { + if (window.location.hash !== `#${targetSectionId}`) { + history.replaceState(null, '', `#${targetSectionId}`); + } + } } - // Read current hash on page load - if (window.location.hash) { - activateTab(window.location.hash); + // Attach click listeners + const allNavButtons = [...menuItems, ...tabPills]; + allNavButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const href = btn.getAttribute('href'); + if (!href) return; + const { sectionId } = getTargetSectionAndPanel(href); + switchSection(sectionId, { updateHash: true }); + }); + }); + + // Check on load: Did server specify an open panel or errors? + const serverOpenPanel = document.querySelector('.settings-panel[data-server-open="true"], .settings-panel:has(.settings-error)'); + if (serverOpenPanel) { + const parentSection = serverOpenPanel.closest('.settings-section'); + if (parentSection) { + switchSection(parentSection.id, { closePanels: false, updateHash: false }); + serverOpenPanel.removeAttribute('hidden'); + const toggleBtn = document.querySelector(`[aria-controls="${serverOpenPanel.id}"]`); + if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'true'); + return; + } + } + + // Otherwise inspect URL hash + const initialHash = window.location.hash; + const { sectionId, panelId } = getTargetSectionAndPanel(initialHash); + switchSection(sectionId, { updateHash: false }); + + if (panelId) { + const targetPanel = document.getElementById(panelId); + if (targetPanel) { + targetPanel.removeAttribute('hidden'); + const toggleBtn = document.querySelector(`[aria-controls="${panelId}"]`); + if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'true'); + } } - // Listen to hash changes + // Listen to external hashchange window.addEventListener('hashchange', () => { - activateTab(window.location.hash); + const { sectionId: newSec, panelId: newPan } = getTargetSectionAndPanel(window.location.hash); + switchSection(newSec, { updateHash: false }); + if (newPan) { + const p = document.getElementById(newPan); + if (p) { + p.removeAttribute('hidden'); + const tb = document.querySelector(`[aria-controls="${newPan}"]`); + if (tb) tb.setAttribute('aria-expanded', 'true'); + } + } }); +} - // Smooth scroll for nav items - navItems.forEach(link => { - link.addEventListener('click', (e) => { - const targetId = link.getAttribute('href'); - if (targetId && targetId.startsWith('#')) { - const targetCard = document.querySelector(targetId); - if (targetCard) { - targetCard.scrollIntoView({ behavior: 'smooth' }); - } +/** + * 2. Disclosure Panels & Mobile Bottom Sheet Controller + */ +function initDisclosurePanels() { + const panels = Array.from(document.querySelectorAll('.settings-panel')); + const toggleButtons = Array.from(document.querySelectorAll('[data-action="toggle-panel"]')); + const backdrop = document.querySelector('.settings-sheet-backdrop'); + + // Hide any panels not explicitly marked open on load + panels.forEach(p => { + if (p.getAttribute('data-server-open') !== 'true' && !p.querySelector('.settings-error')) { + p.setAttribute('hidden', ''); + } + }); + + function isMobileView() { + return window.innerWidth < 640; + } + + function openPanel(panel, triggerBtn) { + // Close any other open panels in document + panels.forEach(other => { + if (other !== panel) { + other.setAttribute('hidden', ''); + const otherBtn = document.querySelector(`[aria-controls="${other.id}"]`); + if (otherBtn) otherBtn.setAttribute('aria-expanded', 'false'); + } + }); + + panel.removeAttribute('hidden'); + if (triggerBtn) { + triggerBtn.setAttribute('aria-expanded', 'true'); + } + + if (isMobileView()) { + document.body.classList.add('settings-sheet-open'); + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-modal', 'true'); + } else { + panel.removeAttribute('role'); + panel.removeAttribute('aria-modal'); + } + + // Focus first input + const firstInput = panel.querySelector('input:not([type="hidden"]), button:not(.settings-sheet-drag-handle)'); + if (firstInput) { + setTimeout(() => firstInput.focus(), 50); + } + } + + function closePanel(panel, returnFocusToTrigger = true) { + panel.setAttribute('hidden', ''); + document.body.classList.remove('settings-sheet-open'); + panel.removeAttribute('role'); + panel.removeAttribute('aria-modal'); + + const triggerBtn = document.querySelector(`[aria-controls="${panel.id}"]`); + if (triggerBtn) { + triggerBtn.setAttribute('aria-expanded', 'false'); + if (returnFocusToTrigger) { + triggerBtn.focus(); + } + } + } + + toggleButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const panelId = btn.getAttribute('aria-controls'); + if (!panelId) return; + const panel = document.getElementById(panelId); + if (!panel) return; + + const isCurrentlyOpen = !panel.hasAttribute('hidden'); + if (isCurrentlyOpen) { + closePanel(panel); + } else { + openPanel(panel, btn); + } + }); + }); + + // Wire up panel internal "Cancel" buttons + document.querySelectorAll('[data-action="close-panel"]').forEach(cancelBtn => { + cancelBtn.addEventListener('click', (e) => { + e.preventDefault(); + const panel = cancelBtn.closest('.settings-panel'); + if (panel) closePanel(panel, true); + }); + }); + + // Click on mobile backdrop closes open sheet + if (backdrop) { + backdrop.addEventListener('click', () => { + const openPan = document.querySelector('.settings-panel:not([hidden])'); + if (openPan) closePanel(openPan, true); + }); + } + + // Escape closes untouched panel/sheet + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + const openPan = document.querySelector('.settings-panel:not([hidden])'); + if (openPan) { + closePanel(openPan, true); + } + } + }); + + // Make mobile rows tappable to open matching panel + document.querySelectorAll('.settings-row[data-panel-id]').forEach(row => { + row.addEventListener('click', (e) => { + if (!isMobileView()) return; + // Don't trigger if user clicked directly on a button or link inside row + if (e.target.closest('button, a, input')) return; + const panelId = row.getAttribute('data-panel-id'); + const panel = document.getElementById(panelId); + const triggerBtn = row.querySelector('[data-action="toggle-panel"]'); + if (panel && triggerBtn) { + openPanel(panel, triggerBtn); } }); }); } -function initAvatarUploader() { +/** + * 3. Avatar Upload & Instant Preview + */ +function initAvatarControls() { const fileInput = document.getElementById('avatar-file-input'); const chooseBtn = document.getElementById('btn-choose-avatar'); const previewImg = document.getElementById('avatar-preview-img'); const fallbackInitial = document.getElementById('avatar-fallback-initial'); - const uploadForm = document.getElementById('avatar-upload-form'); const errorContainer = document.getElementById('avatar-client-error'); if (!fileInput || !chooseBtn) return; @@ -67,13 +301,11 @@ function initAvatarUploader() { const file = fileInput.files && fileInput.files[0]; if (!file) return; - // Clear previous error if (errorContainer) { errorContainer.textContent = ''; errorContainer.style.display = 'none'; } - // Validate client size limit: 2 MB const maxBytes = 2 * 1024 * 1024; if (file.size > maxBytes) { fileInput.value = ''; @@ -84,7 +316,6 @@ function initAvatarUploader() { return; } - // Validate MIME type const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { fileInput.value = ''; @@ -95,23 +326,53 @@ function initAvatarUploader() { return; } - // Client-side preview const reader = new FileReader(); reader.onload = (e) => { if (previewImg) { previewImg.src = e.target.result; previewImg.style.display = 'block'; - if (fallbackInitial) fallbackInitial.style.display = 'none'; } - // Auto submit or highlight upload action - if (uploadForm) { - uploadForm.submit(); + if (fallbackInitial) { + fallbackInitial.style.display = 'none'; } }; reader.readAsDataURL(file); }); } +/** + * 4. Remove Photo Native Dialog Confirmation + */ +function initRemoveAvatarDialog() { + const triggerBtn = document.getElementById('btn-remove-avatar-trigger'); + const dialog = document.getElementById('remove-avatar-dialog'); + const cancelBtn = document.getElementById('btn-dialog-cancel-remove'); + + if (!triggerBtn || !dialog) return; + + triggerBtn.addEventListener('click', (e) => { + e.preventDefault(); + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } else { + // Fallback for older browsers + if (window.confirm('Are you sure you want to remove your avatar?')) { + const form = document.getElementById('avatar-remove-form'); + if (form) form.submit(); + } + } + }); + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + dialog.close(); + }); + } +} + +/** + * 5. 6-Cell OTP Grid Handler + */ function initOtpInputs() { function bindOtpGroup(containerId, fallbackInputId) { const container = document.getElementById(containerId); @@ -122,17 +383,13 @@ function initOtpInputs() { if (boxes.length !== 6) return; function syncToFallback() { - const fullVal = boxes.map(b => b.value.trim()).join(''); - fallbackInput.value = fullVal; - boxes.forEach(b => { - b.classList.toggle('filled', b.value.length > 0); - }); + fallbackInput.value = boxes.map(b => b.value.trim()).join(''); } boxes.forEach((box, index) => { box.addEventListener('input', () => { - const val = box.value.replace(/[^0-9]/g, ''); - box.value = val ? val[0] : ''; + const clean = box.value.replace(/[^0-9]/g, ''); + box.value = clean ? clean[0] : ''; syncToFallback(); if (box.value && index < 5) { boxes[index + 1].focus(); @@ -165,23 +422,19 @@ function initOtpInputs() { box.addEventListener('paste', (e) => { e.preventDefault(); - const pasteData = (e.clipboardData || window.clipboardData).getData('text'); - const cleanDigits = pasteData.replace(/[^0-9]/g, '').slice(0, 6); - if (!cleanDigits) return; + const text = (e.clipboardData || window.clipboardData).getData('text'); + const digits = text.replace(/[^0-9]/g, '').slice(0, 6); + if (!digits) return; - cleanDigits.split('').forEach((digit, i) => { - if (boxes[i]) { - boxes[i].value = digit; - } + digits.split('').forEach((d, i) => { + if (boxes[i]) boxes[i].value = d; }); syncToFallback(); - const focusIndex = Math.min(cleanDigits.length, 5); - boxes[focusIndex].focus(); + const nextIdx = Math.min(digits.length, 5); + boxes[nextIdx].focus(); }); - box.addEventListener('focus', () => { - box.select(); - }); + box.addEventListener('focus', () => box.select()); }); } @@ -189,27 +442,30 @@ function initOtpInputs() { bindOtpGroup('password-otp-boxes-container', 'password-otp-combined-input'); } -function initResendTimer() { +/** + * 6. OTP Resend Timers + */ +function initResendTimers() { function bindTimer(buttonId, textId) { - const resendBtn = document.getElementById(buttonId); - const timerText = document.getElementById(textId); - if (!resendBtn || !timerText) return; - - let cooldownSeconds = parseInt(resendBtn.getAttribute('data-cooldown') || '0', 10); - if (isNaN(cooldownSeconds) || cooldownSeconds <= 0) return; - - resendBtn.disabled = true; - timerText.textContent = `Resend in ${cooldownSeconds}s`; - - const timer = setInterval(() => { - cooldownSeconds -= 1; - if (cooldownSeconds <= 0) { - clearInterval(timer); - resendBtn.disabled = false; - timerText.textContent = 'Resend Code'; - resendBtn.removeAttribute('data-cooldown'); + const btn = document.getElementById(buttonId); + const text = document.getElementById(textId); + if (!btn || !text) return; + + let wait = parseInt(btn.getAttribute('data-cooldown') || '0', 10); + if (isNaN(wait) || wait <= 0) return; + + btn.disabled = true; + text.textContent = `Resend in ${wait}s`; + + const interval = setInterval(() => { + wait -= 1; + if (wait <= 0) { + clearInterval(interval); + btn.disabled = false; + text.textContent = 'Resend Code'; + btn.removeAttribute('data-cooldown'); } else { - timerText.textContent = `Resend in ${cooldownSeconds}s`; + text.textContent = `Resend in ${wait}s`; } }, 1000); } @@ -218,42 +474,46 @@ function initResendTimer() { bindTimer('btn-resend-password-otp', 'resend-pwd-timer-text'); } +/** + * 7. Password Visibility Toggles + */ function initPasswordToggles() { - const toggleButtons = document.querySelectorAll('.settings-password-toggle'); - toggleButtons.forEach(btn => { + const buttons = document.querySelectorAll('.settings-password-toggle-btn'); + buttons.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); - const wrapper = btn.closest('.settings-password-field-wrapper'); + const wrapper = btn.closest('.settings-password-wrapper'); if (!wrapper) return; const input = wrapper.querySelector('input'); - const icon = btn.querySelector('.material-symbols-outlined'); if (!input) return; - if (input.type === 'password') { - input.type = 'text'; - if (icon) icon.textContent = 'visibility_off'; - btn.setAttribute('aria-label', 'Hide password'); - } else { - input.type = 'password'; - if (icon) icon.textContent = 'visibility'; - btn.setAttribute('aria-label', 'Show password'); - } + const isPassword = input.type === 'password'; + input.type = isPassword ? 'text' : 'password'; + btn.setAttribute('aria-label', isPassword ? 'Hide password' : 'Show password'); }); }); } +/** + * 8. Password Strength Indicator + */ function initPasswordStrength() { - function setupStrengthMeter(inputId, barId, hintId) { + function setupStrength(inputId, meterId, hintId) { const input = document.getElementById(inputId); - const bar = document.getElementById(barId); + const meter = document.getElementById(meterId); const hint = document.getElementById(hintId); - if (!input || !bar || !hint) return; + if (!input || !meter) return; + + const segments = Array.from(meter.querySelectorAll('.settings-strength-segment')); input.addEventListener('input', () => { const val = input.value; + segments.forEach(s => { + s.className = 'settings-strength-segment'; + }); + if (!val) { - bar.className = 'settings-strength-bar'; - hint.textContent = 'Strength hint: Enter password (hint only; server validators enforce policy)'; + if (hint) hint.textContent = 'Enter a password to evaluate strength.'; return; } @@ -263,25 +523,24 @@ function initPasswordStrength() { if (/[a-z]/.test(val) && /[A-Z]/.test(val)) score += 1; if (/[0-9]/.test(val) && /[^a-zA-Z0-9]/.test(val)) score += 1; - bar.className = 'settings-strength-bar'; - if (score <= 1) { - bar.classList.add('weak'); - hint.textContent = 'Strength hint: Weak (hint only; server validators enforce policy)'; - } else if (score === 2) { - bar.classList.add('fair'); - hint.textContent = 'Strength hint: Fair (hint only; server validators enforce policy)'; - } else if (score === 3) { - bar.classList.add('good'); - hint.textContent = 'Strength hint: Good (hint only; server validators enforce policy)'; - } else { - bar.classList.add('strong'); - hint.textContent = 'Strength hint: Strong (hint only; server validators enforce policy)'; + for (let i = 0; i < score; i++) { + if (segments[i]) { + if (score === 1) segments[i].classList.add('active-weak'); + else if (score === 2) segments[i].classList.add('active-fair'); + else if (score === 3) segments[i].classList.add('active-good'); + else segments[i].classList.add('active-strong'); + } + } + + if (hint) { + if (score <= 1) hint.textContent = 'Weak password'; + else if (score === 2) hint.textContent = 'Fair password'; + else if (score === 3) hint.textContent = 'Good password'; + else hint.textContent = 'Strong password'; } }); } - setupStrengthMeter('settings-new-password', 'strength-bar-normal', 'strength-hint-normal'); - setupStrengthMeter('settings-otp-new-password', 'strength-bar-otp', 'strength-hint-otp'); + setupStrength('settings-new-password', 'strength-meter-normal', 'strength-hint-normal'); + setupStrength('settings-otp-new-password', 'strength-meter-otp', 'strength-hint-otp'); } - - diff --git a/templates/accounts/settings.html b/templates/accounts/settings.html index 6101c6c..080966b 100644 --- a/templates/accounts/settings.html +++ b/templates/accounts/settings.html @@ -10,607 +10,711 @@ {% block content %}
-
-

Account Settings

-

Manage your identity, authentication credentials, and security audit log.

-
+ +
+

Settings

+

Manage your personal identity, credentials, and review your account activity.

+
+ + + +
- - - - -
- - -
-
-
-

Personal Profile & Avatar

-

Your display name and profile image. Note: Unlike encrypted vault records, avatars are stored unencrypted on server disk and served only to authenticated sessions.

-
+ + + + +
+ + + + +
+ +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %}
- -
-
- {% if has_avatar %} - {{ user.username }} - - {% else %} - - {{ user.username|make_list|first|upper }} - {% endif %} +
+
+

Personal profile

+

Your display name, username identifier, and account photo.

+
+ + +
+
Photo
+
+
+ {% if has_avatar %} + {{ user.username }} + + {% else %} + + {{ user.username|make_list|first|upper }} + {% endif %} +
+
+
+ + +
-
-
-
+ +
-
- - - - -
- {% csrf_token %} - - -
- - {{ profile_form.display_name }} - An optional name (up to 50 characters) displayed in personal vault greetings and user interface chips. - {% if profile_form.display_name.errors %} - - {% endif %} -
- -
- -
-
-
- - -
-
-
-

Username

-

Your unique account identifier. For security and anti-takeover hygiene, usernames may only be changed once every 30 days.

-
- - -
-
-
CURRENT USERNAME
-
@{{ user.username }}
-
-
- {% if cooldown_status.can_change %} - ELIGIBLE TO CHANGE - {% else %} - COOLDOWN ACTIVE - {% endif %} -
-
- {% if not cooldown_status.can_change %} -
- schedule - You can change your username again on {{ cooldown_status.next_change_date|date:"d M Y" }}. -
- {% endif %} - - -
- {% csrf_token %} - - -
- - {{ username_form.new_username }} - 3 to 30 characters. Letters, digits, and underscores. Cannot contain '@' or reserved system words. - {% if username_form.new_username.errors %} - - {% endif %} -
- -
- - {{ username_form.current_password }} - Re-enter your current password to authorize this sensitive change. - {% if username_form.current_password.errors %} - - {% endif %} -
- -
- + +
+
Display name
+
+ {{ profile.display_name|default:"Not configured" }} +
+
+ + +
- -
- -
-
-
-

Email Address

-

Your primary destination for security notifications, password resets, and audit alerts.

-
-
+ +
+ +
+
+ {% csrf_token %} + + +
+ + + An optional name up to 50 characters displayed in greetings and sharing chips. + {% if profile_form.display_name.errors %} + {{ profile_form.display_name.errors.0 }} + {% endif %} +
-
-
-
CURRENT EMAIL
-
- {% if user.email %} - {{ user.email }} - {% else %} - No email address on file - {% endif %} +
+ + +
+
-
- {% if user.email and profile.is_email_verified %} - VERIFIED - {% elif user.email %} - UNVERIFIED - {% else %} - NONE - {% endif %} -
-
- {% if pending_email_change %} - -
-
- mark_email_unread + +
+
Username
+
+ @{{ user.username }} + {% if not cooldown_status.can_change %} + COOLDOWN ACTIVE +
+ + You can change your username again on {{ cooldown_status.next_change_date|date:"d M Y" }} +
+ {% endif %}
-
-
Verification in Progress
-

- A 6-digit confirmation code was sent to {{ pending_email_change.new_email }}. Your email stays unchanged until you enter it. -

+
+ {% if cooldown_status.can_change %} + + + {% endif %}
- -
- {% csrf_token %} - - -
- - - -
- - - - - - -
+ + {% if cooldown_status.can_change %} +
+ +
+ + {% csrf_token %} + + +
+ + + 3 to 30 characters. Letters, digits, and underscores. Cannot contain '@'. + {% if username_form.new_username.errors %} + {{ username_form.new_username.errors.0 }} + {% endif %} +
- -
- {{ email_confirm_form.code }} -
+
+ + + Enter your current password to authorize this sensitive change. + {% if username_form.current_password.errors %} + {{ username_form.current_password.errors.0 }} + {% endif %} +
- Code expires in 15 minutes. 5 attempts maximum. - {% if email_confirm_form.code.errors %} - - {% endif %} +
+ + +
+
- -
- - - -
- {% csrf_token %} - - -
- -
- {% csrf_token %} - - -
- - {% else %} - - {% if not user.email %} -

- Your account currently has no email address configured. Adding an email enables security alerts, change notifications, and self-service password recovery. -

- {% elif not profile.is_email_verified %} -

- Your current email address is unverified. Please verify your address to activate full account recovery capabilities. -

- - verified Enter Verification Code - {% endif %} -
-
-
- {% if user.email %}Change Email Address{% else %}Add Email Address{% endif %} -
- - We'll send a 6-digit code to the new address. Your email stays unchanged until you enter it. - -
- -
- {% csrf_token %} - - -
- - {{ email_request_form.new_email }} - {% if email_request_form.new_email.errors %} - - {% endif %} -
- -
- - {{ email_request_form.current_password }} - Enter your current password to authorize this sensitive change. - {% if email_request_form.current_password.errors %} - - {% endif %} -
- -
- -
-
+ +
+ Avatar photos are stored unencrypted on disk and served only to authenticated sessions.
- {% endif %} +
- -
-
-
-

Password & Authentication

-

Update your master vault password. Changing your password immediately invalidates all other active login sessions.

+ + + +
+
+
+

Sign-in & security

+

Manage your email address, password, and account recovery options.

-
-
-

- For maximum protection, choose a passphrase combining uppercase, lowercase, numbers, and symbols. Changing your password invalidates all other active sessions. -

- - {% if is_auth_locked %} -
- lock_clock - Password change is temporarily locked due to repeated incorrect password attempts. Please try again in {{ lockout_minutes }} minute(s). -
- {% endif %} - - {% if is_reset_authorized %} - -
-
- lock_reset -
-
-
Email Verification Confirmed
-

- You are authorized to define a new master password without entering your current password. This authorization expires in 10 minutes. -

-
-
- -
- {% csrf_token %} - - -
- -
- {{ password_set_new_form.new_password }} - -
-
- Strength hint: Enter password (hint only; server validators enforce policy) - {% if password_set_new_form.new_password.errors %} - + +
+
Email address
+
+ + {% if user.email %} + {{ user.email }} + {% else %} + No email address on file {% endif %} -
- -
- -
- {{ password_set_new_form.confirm_password }} - -
- {% if password_set_new_form.confirm_password.errors %} - + + + {% if user.email and profile.is_email_verified %} + Verified + {% elif user.email %} + Unverified + Verify + {% else %} + None {% endif %} -
- -
- - -
- {% csrf_token %} - - -
+ +
+
+ +
+
- {% elif reset_otp_pending %} - -
-
- mark_email_unread -
-
-
Password Reset Code Dispatched
-

- A 6-digit verification code was sent to {{ user.email }}. Enter it below to unlock password reset without your old password. + +

+ +
+ {% if pending_email_change %} + +
STEP 2 OF 2
+

Check your email

+

+ We sent a 6-digit confirmation code to:

-
-
+
{{ pending_email_change.new_email }}
-
- {% csrf_token %} - - -
- - -
- - - - - - -
+ + + {% csrf_token %} + + +
+ + + +
+ + + + + + +
+ +
+ +
+ + Expires in 15 minutes. 5 attempts maximum. + {% if email_confirm_form.code.errors %} + {{ email_confirm_form.code.errors.0 }} + {% endif %} +
+ +
+ +
+ -
- {{ password_otp_form.code }} + +
+
+ {% csrf_token %} + + +
+ +
+ {% csrf_token %} + + +
- Code expires in 15 minutes. 5 attempts maximum. - {% if password_otp_form.code.errors %} - - {% endif %} -
- -
- - - -
- {% csrf_token %} - - -
- -
- {% csrf_token %} - - -
+ {% else %} + +
+ {% csrf_token %} + + +
+ + + We'll send a 6-digit confirmation code to this address. + {% if email_request_form.new_email.errors %} + {{ email_request_form.new_email.errors.0 }} + {% endif %} +
+ +
+ + + Enter your current password to authorize this sensitive change. + {% if email_request_form.current_password.errors %} + {{ email_request_form.current_password.errors.0 }} + {% endif %} +
+ +
+ + +
+
+ {% endif %}
+
- {% else %} - -
- {% csrf_token %} - - -
- -
- {{ password_normal_form.current_password }} - + +
+
Password
+
+ •••••••••••• + {% if is_auth_locked %} +
+ + Password change is locked. Please try again in {{ lockout_minutes }} minute(s).
- Enter your current master password to authorize change. - {% if password_normal_form.current_password.errors %} - - {% endif %} -
+ {% elif is_reset_authorized %} + Reset Authorized + {% elif reset_otp_pending %} + Code Pending + {% endif %} +
+
+ {% if not is_auth_locked %} + + + {% endif %} +
+
-
- -
- {{ password_normal_form.new_password }} - + + {% if not is_auth_locked %} +
+ +
+ {% if is_reset_authorized %} + +
+ Email verification confirmed. You can now define a new password without entering your old password. This authorization expires in 10 minutes.
-
- Strength hint: Enter password (hint only; server validators enforce policy) - {% if password_normal_form.new_password.errors %} - - {% endif %} -
-
- -
- {{ password_normal_form.confirm_password }} - -
- {% if password_normal_form.confirm_password.errors %} - - {% endif %} -
+ + {% csrf_token %} + + +
+ +
+ + +
+
+
+
+
+
+
+ Enter a password to evaluate strength. + {% if password_set_new_form.new_password.errors %} + {{ password_set_new_form.new_password.errors.0 }} + {% endif %} +
+ +
+ +
+ + +
+ {% if password_set_new_form.confirm_password.errors %} + {{ password_set_new_form.confirm_password.errors.0 }} + {% endif %} +
+ +
+ +
+ -
- -
- +
+ {% csrf_token %} + + +
- -
-
- Don't remember your current password? -
- {% if can_use_recovery %} -

- We can send a 6-digit verification code to your verified email ({{ user.email }}) to verify your identity. + {% elif reset_otp_pending %} + +

STEP 2 OF 2
+

Enter password reset code

+

+ We sent a 6-digit verification code to:

-
+
{{ user.email }}
+ + {% csrf_token %} - - + + +
+ + +
+ + + + + + +
+ +
+ +
+ + Expires in 15 minutes. 5 attempts maximum. + {% if password_otp_form.code.errors %} + {{ password_otp_form.code.errors.0 }} + {% endif %} +
+ +
+ +
+ +
+
+ {% csrf_token %} + + +
+ +
+ {% csrf_token %} + + +
+
+ {% else %} -
- info - Self-service password reset requires an added and verified email address. Please configure and verify your email in the section above first. + +
+ {% csrf_token %} + + +
+ +
+ + +
+ Enter your current password to authorize change. + {% if password_normal_form.current_password.errors %} + {{ password_normal_form.current_password.errors.0 }} + {% endif %} +
+ +
+ +
+ + +
+
+
+
+
+
+
+ Enter a password to evaluate strength. + {% if password_normal_form.new_password.errors %} + {{ password_normal_form.new_password.errors.0 }} + {% endif %} +
+ +
+ +
+ + +
+ {% if password_normal_form.confirm_password.errors %} + {{ password_normal_form.confirm_password.errors.0 }} + {% endif %} +
+ +
+ + +
+
+ + +
+ {% if can_use_recovery %} +
+ {% csrf_token %} + + +
+ {% else %} + + Self-service password reset requires a verified email address. Configure and verify email first. + + {% endif %}
{% endif %}
+
{% endif %}
- -
-
-
-

Recent Account Activity

+ + + +
+
+
+

Account activity

The 10 most recent security events recorded on your account.

-
- {% if recent_activity %} -
- - - - - - - - - - - {% for event in recent_activity %} + {% if recent_activity %} + +
+
EventIP AddressDevice / AgentTimestamp
+ - - - - + + + + - {% endfor %} - -
- {{ event.get_event_type_display }} - - {{ event.ip_address|default:"—" }} - - {{ event.user_agent|truncatechars:35|default:"—" }} - - {{ event.created_at|date:"d M Y, H:i" }} - EventIP AddressDevice / AgentTimestamp
-
- {% else %} -
- history_toggle_off - No recent account activity recorded yet. -
- {% endif %} + + + {% for event in recent_activity %} + + + {{ event.get_event_type_display }} + + + {{ event.ip_address|default:"—" }} + + + {{ event.user_agent|truncatechars:35|default:"—" }} + + + {{ event.created_at|date:"d M Y, H:i" }} + + + {% endfor %} + + +
+ + +
+ {% for event in recent_activity %} +
+
{{ event.get_event_type_display }}
+
+ {{ event.user_agent|truncatechars:28|default:"Browser" }} · {{ event.ip_address|default:"127.0.0.1" }} +
+
+ {{ event.created_at|date:"d M Y, H:i" }} +
+
+ {% endfor %} +
+ {% else %} +
+ +

No recent account activity recorded yet.

+
+ {% endif %} +
+ + +
{% endblock %} diff --git a/tests/unit/test_settings_redesign.py b/tests/unit/test_settings_redesign.py new file mode 100644 index 0000000..f76d6c5 --- /dev/null +++ b/tests/unit/test_settings_redesign.py @@ -0,0 +1,312 @@ +""" +Unit and integration tests for Account Settings redesign: +- Section switching & progressive enhancement (no-JS renders all sections stacked and accessible). +- Legacy anchor mapping targets (#profile, #username, #email, #password). +- Server-driven state preservation (open panel on validation errors, pending email, or reset OTP). +- Zero inline styles and SVG sprite iconography integrity. +- Preservation of all 13 security/form actions and critical test element IDs. +""" + +from datetime import timedelta +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.utils import timezone + +from accounts.models import ( + UserProfile, + PendingEmailChange, + EmailVerificationCode, + ActionThrottle, + AccountEvent, +) +from accounts.services.password_service import PASSWORD_RESET_SESSION_KEY +from tests.unit import get_test_password + +User = get_user_model() + + +class TestSettingsRedesignPhase1(TestCase): + """Test suite for Phase 1: Section layout, progressive enhancement, and hash mapping targets.""" + + def setUp(self): + self.password = get_test_password() + self.user = User.objects.create_user( + username="redesign_tester", + email="tester@passli.dev", + password=self.password, + ) + self.profile = UserProfile.objects.get(user=self.user) + self.profile.is_email_verified = True + self.profile.display_name = "Redesign Tester" + self.profile.save() + self.client.force_login(self.user) + self.url = reverse("account_settings") + + def test_progressive_enhancement_no_js_renders_all_sections_and_panels(self): + """Without JavaScript, all three sections (profile, security, activity) and panels are in the DOM without inline display:none.""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + # 1. Three core sections must be present + self.assertIn('id="profile"', content) + self.assertIn('id="security"', content) + self.assertIn('id="activity"', content) + + # 2. Both desktop menu and mobile pill tab bar must exist + self.assertIn('class="settings-menu"', content) + self.assertIn('class="settings-tab-bar"', content) + + # 3. SVG sprite icons must be referenced + self.assertIn("settings-sprite.svg#icon-", content) + + # 4. Zero inline style="display:none" on sections or cards + self.assertNotIn('style="display: none;"', content) + self.assertNotIn('style="display:none"', content) + + def test_legacy_anchor_targets_and_disclosure_ids_exist(self): + """Legacy redirect targets (#profile, #username, #email, #password) must exist for JS router mapping.""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + # Core section anchors + self.assertIn('id="profile"', content) + self.assertIn('id="security"', content) + self.assertIn('id="activity"', content) + + # Legacy anchor / disclosure targets + self.assertIn('id="row-username"', content) + self.assertIn('id="panel-username"', content) + self.assertIn('id="row-email"', content) + self.assertIn('id="panel-email"', content) + self.assertIn('id="row-password"', content) + self.assertIn('id="panel-password"', content) + + def test_server_state_auto_opens_pending_email_panel(self): + """When an email change is pending, the email panel is marked for auto-opening on load.""" + PendingEmailChange.objects.create(user=self.user, new_email="newdest@passli.dev") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + # Email panel should indicate server-open state + self.assertIn('id="panel-email"', content) + self.assertIn('data-server-open="true"', content) + self.assertIn("newdest@passli.dev", content) + + def test_server_state_auto_opens_pending_password_otp_panel(self): + """When a password reset OTP is pending, the password panel is marked for auto-opening.""" + session = self.client.session + session["pwd_reset_otp_requested"] = True + session.save() + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + self.assertIn('id="panel-password"', content) + self.assertIn('data-server-open="true"', content) + self.assertIn("form-confirm-password-otp", content) + + def test_server_state_auto_opens_authorized_password_reset_panel(self): + """When user is in the 10-minute authorized reset window, the password panel is open.""" + session = self.client.session + session[PASSWORD_RESET_SESSION_KEY] = timezone.now().isoformat() + session.save() + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + self.assertIn('id="panel-password"', content) + self.assertIn('data-server-open="true"', content) + self.assertIn("form-set-password-after-otp", content) + + +class TestSettingsFeatureParity(TestCase): + """ + Parity contract enforcement: Assert that every row in the feature parity + contract exists across its respective server states, including all 13 POST actions + and all mandatory test element IDs. + """ + + def setUp(self): + self.password = get_test_password() + self.user = User.objects.create_user( + username="parity_user", + email="parity@passli.dev", + password=self.password, + ) + self.profile = UserProfile.objects.get(user=self.user) + self.profile.is_email_verified = True + self.profile.display_name = "Parity User" + self.profile.save() + self.client.force_login(self.user) + self.url = reverse("account_settings") + + def test_all_thirteen_actions_contract(self): + """Verify the exact presence of all 13 POST actions in their appropriate states.""" + # ------------------------------------------------------------- + # STATE A: Default State (Avatar, Profile, Username, Email Normal, Password Normal, Activity) + # ------------------------------------------------------------- + # 1. Setup avatar + self.profile.avatar_path = "avatars/test.webp" + self.profile.save() + + # Add 3 activity events + for i in range(3): + AccountEvent.objects.create( + user=self.user, + event_type=AccountEvent.EventType.PASSWORD_CHANGED, + ip_address="192.168.1.50", + user_agent="TestBrowser/1.0", + ) + + resp_default = self.client.get(self.url) + content_default = resp_default.content.decode("utf-8") + + # Action 1: upload_avatar + self.assertIn('value="upload_avatar"', content_default) + self.assertIn('id="avatar-file-input"', content_default) + self.assertIn('id="btn-choose-avatar"', content_default) + + # Action 2: remove_avatar (present when avatar exists) + self.assertIn('value="remove_avatar"', content_default) + self.assertIn('id="btn-remove-avatar"', content_default) + self.assertIn('id="remove-avatar-dialog"', content_default) + + # Action 3: update_profile + self.assertIn('value="update_profile"', content_default) + self.assertIn('id="btn-save-profile"', content_default) + self.assertIn('id="profile-display-name"', content_default) + + # Action 4: change_username (eligible when cooldown is false) + self.assertIn('value="change_username"', content_default) + self.assertIn('id="btn-submit-username"', content_default) + self.assertIn('id="settings-new-username"', content_default) + self.assertIn('id="settings-username-password"', content_default) + + # Action 5: request_email_change (when no pending email change) + self.assertIn('value="request_email_change"', content_default) + self.assertIn('id="btn-request-email-change"', content_default) + self.assertIn('id="settings-new-email"', content_default) + self.assertIn('id="settings-email-password"', content_default) + + # Action 9: change_password_normal (normal password state) + self.assertIn('value="change_password_normal"', content_default) + self.assertIn('id="btn-submit-password-change"', content_default) + self.assertIn('id="settings-current-password"', content_default) + self.assertIn('id="settings-new-password"', content_default) + self.assertIn('id="settings-confirm-password"', content_default) + + # Action 10: request_password_otp (recovery path when email is verified) + self.assertIn('value="request_password_otp"', content_default) + self.assertIn('id="btn-forgot-password-otp"', content_default) + + # Activity table check + self.assertIn('class="settings-activity-table"', content_default) + self.assertIn("192.168.1.50", content_default) + + # ------------------------------------------------------------- + # STATE B: Pending Email Change Flow + # ------------------------------------------------------------- + PendingEmailChange.objects.create(user=self.user, new_email="target_address@passli.dev") + resp_pending_email = self.client.get(self.url) + content_email = resp_pending_email.content.decode("utf-8") + + # Action 6: confirm_email_change + self.assertIn('value="confirm_email_change"', content_email) + self.assertIn('id="btn-submit-email-otp"', content_email) + self.assertIn('id="email-otp-combined-input"', content_email) + self.assertIn('id="email-otp-boxes-container"', content_email) + + # Action 7: resend_email_change_otp + self.assertIn('value="resend_email_change_otp"', content_email) + self.assertIn('id="btn-resend-email-otp"', content_email) + + # Action 8: cancel_email_change + self.assertIn('value="cancel_email_change"', content_email) + self.assertIn('id="form-cancel-email-change"', content_email) + + # ------------------------------------------------------------- + # STATE C: Pending Password Reset OTP Flow + # ------------------------------------------------------------- + session = self.client.session + session["pwd_reset_otp_requested"] = True + session.save() + + resp_pwd_otp = self.client.get(self.url) + content_pwd_otp = resp_pwd_otp.content.decode("utf-8") + + # Action 11: verify_password_otp + self.assertIn('value="verify_password_otp"', content_pwd_otp) + self.assertIn('id="btn-submit-pwd-otp"', content_pwd_otp) + self.assertIn('id="password-otp-combined-input"', content_pwd_otp) + self.assertIn('id="password-otp-boxes-container"', content_pwd_otp) + + # Resend password OTP (uses request_password_otp action) + self.assertIn('value="request_password_otp"', content_pwd_otp) + self.assertIn('id="btn-resend-password-otp"', content_pwd_otp) + + # Action 13: cancel_password_reset + self.assertIn('value="cancel_password_reset"', content_pwd_otp) + + # ------------------------------------------------------------- + # STATE D: Authorized Password Reset Flow (10-minute window) + # ------------------------------------------------------------- + session["pwd_reset_otp_requested"] = False + session[PASSWORD_RESET_SESSION_KEY] = timezone.now().isoformat() + session.save() + + resp_auth = self.client.get(self.url) + content_auth = resp_auth.content.decode("utf-8") + + # Action 12: set_password_after_otp + self.assertIn('value="set_password_after_otp"', content_auth) + self.assertIn('id="btn-submit-otp-new-password"', content_auth) + self.assertIn('id="settings-otp-new-password"', content_auth) + self.assertIn('id="settings-otp-confirm-password"', content_auth) + + # Action 13 also available in authorized reset + self.assertIn('value="cancel_password_reset"', content_auth) + + def test_cooldown_username_state_hides_form_and_renders_cooldown_badge(self): + """When username is in cooldown, change button is replaced by cooldown message.""" + self.profile.username_changed_at = timezone.now() - timedelta(days=5) + self.profile.save() + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + self.assertIn("COOLDOWN ACTIVE", content) + self.assertIn("You can change your username again on", content) + # Button to toggle username change should NOT exist + self.assertNotIn('id="btn-toggle-username"', content) + + def test_password_locked_state_hides_form_and_renders_locked_notice(self): + """When password change is throttled, change button is replaced by lock notice.""" + ActionThrottle.objects.create( + user=self.user, + action="password_change", + failed_attempts=5, + locked_until=timezone.now() + timedelta(minutes=15), + ) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + self.assertIn("Password change is locked", content) + self.assertNotIn('id="btn-toggle-password"', content) + + def test_empty_activity_renders_empty_state_cleanly(self): + """When no activity events exist, empty state is displayed.""" + AccountEvent.objects.filter(user=self.user).delete() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + content = response.content.decode("utf-8") + + self.assertIn("No recent account activity recorded yet.", content)