domma-cms 0.88.0 → 0.88.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/login-arrange.js +1 -1
- package/admin/js/templates/login.html +74 -52
- package/admin/js/views/login.js +1 -11
- package/package.json +1 -1
- package/plugins/free-tier.lock.json +10 -9
- package/plugins/security/CLAUDE.md +7 -0
- package/plugins/security/admin/lib/resets.js +82 -0
- package/plugins/security/admin/lib/settings.js +1 -1
- package/plugins/security/admin/templates/security.html +3 -0
- package/plugins/security/admin/views/security.js +23 -10
- package/plugins/security/plugin.js +39 -1
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/security.test.js +34 -0
- package/server/routes/api/forms.js +3 -0
- package/server/services/siteGitignore.js +114 -3
package/admin/css/admin.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.dashboard-layout{display:flex;flex-direction:column;height:100vh;min-height:100vh;overflow:hidden}.dashboard-wrapper{display:flex;flex:1;min-height:0;overflow:hidden}.dashboard-main{flex:1;min-width:0;overflow-y:auto}#admin-sidebar.dashboard-sidebar{display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}#admin-sidebar .dm-admin-sidebar{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--dm-border) transparent}#admin-sidebar .dm-admin-sidebar::-webkit-scrollbar{width:8px}#admin-sidebar .dm-admin-sidebar::-webkit-scrollbar-track{background:transparent}#admin-sidebar .dm-admin-sidebar::-webkit-scrollbar-thumb{background:var(--dm-border);border-radius:4px}#admin-sidebar .dm-admin-sidebar::-webkit-scrollbar-thumb:hover{background:var(--dm-text-muted)}.view-container{padding:1.5rem 2rem;max-width:1200px}.view-container:has(>.dm-plugin-shell){max-width:100%}.view-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem}.view-header h1{font-size:1.5rem;font-weight:700;margin:0;display:flex;align-items:center;gap:.5rem}.view-header-actions{display:flex;gap:.75rem;flex-wrap:wrap}.view-header-actions .btn-primary,.view-header-actions .btn-success,.view-header-actions .btn-danger{margin-left:.5rem}.stat-card .card-body{display:flex;align-items:center;gap:1rem}.stat-icon{width:44px;height:44px;border-radius:8px;background:color-mix(in srgb,var(--dm-primary) 14%,transparent);color:var(--dm-primary, #5b8cff);display:flex;align-items:center;justify-content:center;font-size:1.25rem;flex-shrink:0}.stat-icon--success{background:color-mix(in srgb,var(--dm-success) 15%,transparent);color:var(--dm-success)}.stat-icon--warning{background:color-mix(in srgb,var(--dm-warning) 15%,transparent);color:var(--dm-warning)}.stat-value{font-size:1.75rem;font-weight:700;line-height:1}.stat-label{font-size:.8rem;color:var(--dm-text-muted, #888);margin-top:.25rem}.toolbar{display:flex;align-items:center;gap:.75rem}.form-check-label{display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:.9rem}.form-hint{display:block;font-size:.8rem;color:var(--dm-text-muted, #888);margin-top:.25rem}.dashboard-main:has(#editor-meta-tabs){overflow:hidden}.view-container:has(#editor-meta-tabs){display:flex;flex-direction:column;height:calc(100dvh - 60px);max-width:100%;padding-bottom:0}.view-container:has(#block-editor-body){max-width:100%}.view-container:has(#ep-editor-tabs){max-width:100%}.ep-editor-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr) minmax(0,1.3fr);gap:1rem;align-items:start}.ep-editor-grid>.ep-tryit-col,.ep-editor-grid>.ep-results-col{position:sticky;top:1rem;display:flex;flex-direction:column;gap:1rem;min-width:0}@media(max-width:1500px){.ep-editor-grid{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.ep-editor-grid>:first-child{grid-column:1 / -1}.ep-editor-grid>.ep-tryit-col,.ep-editor-grid>.ep-results-col{position:static}}@media(max-width:1024px){.ep-editor-grid{grid-template-columns:minmax(0,1fr)}}#editor-meta-tabs{flex:1;min-height:0;display:flex;flex-direction:column}#editor-meta-tabs .tab-content{flex:1;min-height:0;overflow:hidden}#editor-meta-tabs .tab-panel{height:100%;overflow-y:auto}#editor-meta-tabs .tab-panel:has(.editor-card),#live-preview-panel{overflow:hidden}.live-preview-bar{display:flex;align-items:center;gap:var(--dm-space-4);flex-wrap:wrap;padding:var(--dm-space-2) 0;border-bottom:1px solid var(--dm-border);margin-bottom:var(--dm-space-2)}.live-preview-widths{display:inline-flex;border:1px solid var(--dm-border);border-radius:var(--dm-radius-sm);overflow:hidden}.live-preview-width{display:inline-flex;align-items:center;justify-content:center;padding:.3rem .55rem;border:none;background:transparent;color:inherit;cursor:pointer;line-height:1}.live-preview-width+.live-preview-width{border-left:1px solid var(--dm-border)}.live-preview-width:hover{background:var(--dm-hover-bg)}.live-preview-width.active{background:var(--dm-active-bg)}.live-preview-status{flex:1 1 auto;font-size:.8rem;opacity:.75}.live-preview-actions{display:flex;gap:var(--dm-space-1)}.live-preview-stage{display:flex;justify-content:center;height:calc(100vh - 230px);overflow:auto;background:var(--dm-background-alt);border-radius:var(--dm-radius-md)}.page-live-preview-frame{display:block;width:100%;max-width:100%;height:100%;border:none;border-radius:6px;background:var(--dm-background);transition:max-width .18s ease}.page-live-preview-frame[data-width="1024"]{max-width:1024px}.page-live-preview-frame[data-width="390"]{max-width:390px}.page-live-preview-frame[data-width="1024"],.page-live-preview-frame[data-width="390"]{border-left:1px solid var(--dm-border);border-right:1px solid var(--dm-border)}.editor-card{display:flex;flex-direction:column;height:100%}.editor-card .card-body{display:flex;flex-direction:column;flex:1;min-height:0;padding:0!important}.editor-toolbar{display:flex;align-items:center;gap:2px;padding:6px 10px;border-bottom:1px solid var(--dm-border, rgba(255, 255, 255, .08));flex-wrap:wrap;flex-shrink:0}.editor-toolbar-btn{background:none;border:none;color:var(--dm-text-muted, #888);padding:6px 8px;border-radius:4px;cursor:pointer;display:flex;align-items:center;transition:color .15s,background .15s}.editor-toolbar-btn:hover{color:var(--dm-text, #eee);background:var(--dm-hover-bg)}.editor-toolbar-sep{width:1px;height:20px;background:var(--dm-border, rgba(255, 255, 255, .08));margin:0 4px;flex-shrink:0}.editor-toolbar-right{margin-left:auto;display:flex;align-items:center;gap:2px}.editor-view-btn{background:none;border:none;color:var(--dm-text-muted, #888);padding:6px 8px;border-radius:4px;cursor:pointer;display:flex;align-items:center;transition:color .15s,background .15s}.editor-view-btn:hover{color:var(--dm-text, #eee);background:var(--dm-hover-bg)}.editor-view-btn.active{color:var(--dm-primary, #5b8cff);background:color-mix(in srgb,var(--dm-primary) 14%,transparent)}.editor-body{display:flex;flex:1;min-height:0;--dm-editor-split: 50%}@media(min-width:769px){.editor-body:not(.editor-mode-write):not(.editor-mode-preview) .editor-pane--write{flex:0 0 var(--dm-editor-split, 50%)}.editor-body:not(.editor-mode-write):not(.editor-mode-preview) .editor-pane--preview{flex:1 1 0}}.editor-pane{flex:1;display:flex;flex-direction:column;min-width:0}.editor-pane--write,.editor-pane--preview{overflow:hidden}.editor-pane--write{flex-direction:row}.editor-line-numbers{padding:1rem .6rem 1rem .75rem;background:var(--dm-background-alt, rgba(0, 0, 0, .15));border-right:1px solid var(--dm-border, rgba(255, 255, 255, .08));color:var(--dm-text-muted, #666);font-family:Fira Code,Courier New,monospace;font-size:.9rem;line-height:1.6;text-align:right;user-select:none;white-space:pre;overflow:hidden;flex-shrink:0;min-width:2.5rem}.editor-line-numbers--foldable{padding:1rem 0;min-width:3rem;white-space:normal;text-align:left;display:flex;flex-direction:column}.editor-line-number-row{display:flex;align-items:center;justify-content:flex-end;padding-right:.6rem;gap:.15rem}.fold-toggle{display:inline-flex;align-items:center;justify-content:center;width:1em;font-size:.6rem;cursor:default;color:transparent}.fold-toggle--open,.fold-toggle--folded{cursor:pointer;color:var(--dm-text-muted, #666);transition:color .1s}.fold-toggle--open:hover,.fold-toggle--folded:hover{color:var(--dm-primary, #4a9eff)}.editor-line-num{min-width:1.5rem;text-align:right}.editor-mode-write .editor-pane--preview,.editor-mode-write .editor-divider,.editor-mode-preview .editor-pane--write,.editor-mode-preview .editor-divider{display:none}.editor-divider{width:7px;background:linear-gradient(to right,transparent 0,transparent 3px,var(--dm-border, rgba(255,255,255,.08)) 3px,var(--dm-border, rgba(255,255,255,.08)) 4px,transparent 4px,transparent 100%);cursor:col-resize;flex-shrink:0;touch-action:none;transition:background .12s ease}.editor-divider:hover,.editor-divider:focus-visible,.editor-divider.is-dragging{background:linear-gradient(to right,transparent 0,transparent 2px,var(--dm-primary, #5b8cff) 2px,var(--dm-primary, #5b8cff) 5px,transparent 5px,transparent 100%);outline:none}.editor-body.is-resizing .editor-pane{pointer-events:none}.editor-body.is-resizing{cursor:col-resize;user-select:none}.editor-textarea{flex:1;resize:none;border:none;border-radius:0;background:transparent;font-family:Fira Code,Courier New,monospace;font-size:.9rem;padding:1rem;outline:none;color:inherit;line-height:1.6}.editor-preview{flex:1;display:block;width:100%;border:none;background:var(--dm-background)}.editor-fullscreen{position:fixed;inset:0;z-index:1000;border-radius:0;margin:0}.editor-fullscreen .card-body{height:100vh}.media-picker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:.75rem;max-height:400px;overflow-y:auto;padding:.25rem}.media-picker-item{cursor:pointer;border:2px solid transparent;border-radius:6px;overflow:hidden;transition:border-color .15s}.media-picker-item:hover{border-color:var(--dm-primary, #5b8cff)}.media-picker-item img{width:100%;height:80px;object-fit:cover;display:block}.media-picker-item span{display:block;font-size:.75rem;padding:4px 6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.docs-body h3{margin-top:1.25rem;margin-bottom:.5rem}.docs-body p{margin-bottom:.5rem;line-height:1.6}.docs-body ol,.docs-body ul{margin-bottom:.75rem;padding-left:1.25rem}.docs-body li{margin-bottom:.25rem}.docs-body hr{margin:1.25rem 0;border-color:var(--dm-border, rgba(255, 255, 255, .08))}.docs-body .table{margin-bottom:.75rem}.nav-items-header,.nav-item-row{display:flex;gap:.5rem;align-items:center;padding:.35rem .75rem}.nav-items-header{border-bottom:1px solid var(--dm-border, rgba(255,255,255,.08));padding-bottom:.4rem;margin-bottom:.25rem}.nav-item-row{border-bottom:1px solid var(--dm-border, rgba(255,255,255,.04))}.nav-item-row:last-child{border-bottom:none}.nav-col-indent{width:18px;flex-shrink:0;color:var(--dm-text-muted, #888);text-align:center;font-size:.85rem}.nav-col-main{flex:2;min-width:0}.nav-col-icon{flex:0 0 90px}.nav-col-parent{flex:2;min-width:0}.nav-col-action{flex-shrink:0}.nav-item-row--child{background:color-mix(in srgb,var(--dm-text) 3%,transparent)}.nav-col-action{display:flex;align-items:center;gap:.25rem}.nav-item-row--hidden{opacity:.45}.nav-item-row--hidden .item-text,.nav-item-row--hidden .footer-link-text{text-decoration:line-through}.btn-toggle-hidden{color:var(--dm-text-muted, #888)}.btn-toggle-hidden.active{color:var(--dm-warning, #f59e0b)}.fb-field-card.fb-drag-over{outline:2px dashed var(--dm-primary, #6366f1);outline-offset:-2px}.btn-ghost span[data-icon=trash]{color:var(--dm-danger, #dc2626)}.view-container:has(.fb-fields-layout){max-width:none}.fb-fields-layout{display:grid;grid-template-columns:minmax(0,6fr) minmax(320px,4fr);gap:1.25rem;align-items:start}.fb-preview-pane{position:sticky;top:1rem;min-width:0}@media(max-width:1100px){.fb-fields-layout{grid-template-columns:1fr}.fb-preview-pane{position:static}}.toggle-label{display:flex;align-items:center;gap:.4rem;font-size:.9rem;cursor:pointer;margin-right:1rem}.preset-toggles{display:flex;flex-wrap:wrap;margin-top:.75rem}.presets-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}@media(max-width:900px){.presets-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:600px){.presets-grid{grid-template-columns:1fr}}.card-header{padding:.5rem 1rem}.card-header h2{margin:0;font-size:.9rem;font-weight:600;letter-spacing:.02em}.preset-card .card-header{display:flex;align-items:center;justify-content:space-between}.preset-card .card-header h3{margin:0;font-size:1rem}.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem}.media-card{background:var(--dm-card-bg, rgba(255,255,255,.04));border:1px solid var(--dm-border, rgba(255,255,255,.08));border-radius:8px;overflow:hidden}.media-preview{height:130px;display:flex;align-items:center;justify-content:center;background:#0003;overflow:hidden}.media-thumb{width:100%;height:100%;object-fit:cover}.media-thumb--file{font-size:2rem;color:var(--dm-text-muted, #888)}.media-info{padding:.5rem .75rem;display:flex;flex-direction:column;gap:.15rem}.media-name{font-size:.8rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:default}.media-rename-input{width:100%;font-size:.8rem;font-weight:500;padding:.1rem .25rem;border:1px solid var(--dm-primary, #7c6af7);border-radius:3px;background:var(--dm-input-bg, transparent);color:inherit;outline:none}.media-size{font-size:.75rem;color:var(--dm-text-muted, #888)}.media-actions{padding:.5rem .75rem;display:flex;gap:.4rem;border-top:1px solid var(--dm-border, rgba(255,255,255,.08))}#admin-topbar{height:60px;display:flex;align-items:center;gap:1rem;padding:0 1.25rem;background:var(--dm-navbar-bg, rgba(0,0,0,.3));border-bottom:1px solid var(--dm-border, rgba(255,255,255,.08));position:sticky;top:0;z-index:200;flex-shrink:0}.topbar-brand{display:flex;align-items:center;gap:.5rem;font-weight:700;font-size:.95rem;color:inherit;flex-shrink:0;margin-right:.5rem}.topbar-brand span[data-icon],.topbar-brand svg{color:var(--dm-primary, #5b8cff);width:22px;height:22px}.topbar-brand-text{opacity:.85}.topbar-brand-logo{height:28px;width:auto;display:inline-block;vertical-align:middle;margin-right:.4em;object-fit:contain}.topbar-user{display:flex;align-items:center;gap:.6rem;flex:1}.topbar-user-name{font-size:.875rem;font-weight:500}.topbar-avatar{width:1.75rem;height:1.75rem;border-radius:50%;object-fit:cover;flex-shrink:0}.topbar-role-badge{font-size:.7rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;padding:.15rem .45rem;border-radius:4px}.topbar-role-badge--admin{background:color-mix(in srgb,var(--dm-primary) 15%,transparent);color:var(--dm-primary)}.topbar-role-badge--manager{background:color-mix(in srgb,var(--dm-success) 15%,transparent);color:var(--dm-success)}.topbar-role-badge--editor{background:color-mix(in srgb,var(--dm-warning) 15%,transparent);color:var(--dm-warning)}.topbar-role-badge--subscriber{background:color-mix(in srgb,var(--dm-text-muted) 15%,transparent);color:var(--dm-text-muted)}.topbar-actions{display:flex;align-items:center;gap:.5rem;flex-shrink:0}.topbar-action-link{display:flex;align-items:center;gap:.35rem;font-size:.875rem;color:var(--dm-text-muted, #888);text-decoration:none;padding:.4rem .65rem;border-radius:6px;transition:color .15s,background .15s}.topbar-action-link:hover{color:var(--dm-text, #eee);background:var(--dm-hover-bg)}.topbar-action-link span[data-icon],.topbar-action-link svg{width:16px;height:16px}.topbar-signout{display:flex;align-items:center;gap:.35rem;font-size:.875rem}.topbar-signout span[data-icon],.topbar-signout svg{width:16px;height:16px}.login-wrap{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:1rem;background:var(--dm-background, #0f1117);z-index:100;overflow-y:auto}.login-card{width:100%;max-width:460px}.ob-done-icon{font-size:3rem;color:var(--dm-success);margin-bottom:1rem;text-align:center}.btn-skip{display:block;text-align:center;margin-top:.75rem;font-size:.85rem;color:var(--dm-text-muted, #888);text-decoration:none}.btn-skip:hover{text-decoration:underline}.theme-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:.6rem}.theme-swatch{border:2px solid var(--dm-border, rgba(255,255,255,.1));border-radius:8px;overflow:hidden;cursor:pointer;transition:border-color .15s,transform .1s}.theme-swatch:hover{border-color:var(--dm-primary, #5b8cff);transform:translateY(-1px)}.theme-swatch.selected{border-color:var(--dm-primary, #5b8cff);box-shadow:0 0 0 2px var(--dm-primary, #5b8cff)}.theme-swatch-preview{height:52px;position:relative;display:flex;align-items:flex-end;padding:.35rem}.theme-swatch-accent{width:28px;height:6px;border-radius:3px}.theme-swatch-label{display:flex;justify-content:space-between;align-items:center;padding:.3rem .45rem;font-size:.7rem;background:var(--dm-card-bg, rgba(255,255,255,.04))}.theme-swatch-mode{color:var(--dm-text-muted, #888);font-size:.65rem}.login-logo{display:flex;align-items:center;gap:.75rem;margin-bottom:1.75rem;font-size:1.5rem}.login-logo span[data-icon]{font-size:2rem;color:var(--dm-primary, #5b8cff)}.login-logo h1{margin:0;font-size:1.5rem;font-weight:700}.login-heading{font-size:1.1rem;font-weight:600;margin-bottom:1.25rem}.login-rules{margin:.25rem 0 .75rem;padding-left:1.1rem;color:var(--dm-danger);font-size:.85rem}.login-rules li+li{margin-top:.2rem}.login-small{font-size:.8rem;margin-top:.75rem}.login-icon-warn{color:var(--dm-warning)}.pwf{position:relative;display:block}.pwf>input{width:100%;padding-right:2.5rem}.pwf-toggle{position:absolute;top:50%;right:.35rem;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;padding:0;border:0;border-radius:var(--dm-radius-sm);background:transparent;color:var(--dm-text-muted);cursor:pointer}.pwf-toggle:hover,.pwf-toggle:focus-visible{color:var(--dm-text);background:var(--dm-hover-bg)}.pwf-toggle svg{width:16px;height:16px}.pwf-toggle [data-icon=eye-off],.pwf.is-shown .pwf-toggle [data-icon=eye]{display:none}.pwf.is-shown .pwf-toggle [data-icon=eye-off]{display:inline-block}.pwf-caps{display:block;margin-top:.25rem;color:var(--dm-warning);font-size:.8rem}.pwf-caps[hidden]{display:none}.btn-block{width:100%}.plugins-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}.plugin-card{display:flex;flex-direction:column;--plugin-accent: var(--dm-primary, #5b8cff);position:relative;overflow:hidden;background-image:radial-gradient(120% 90% at 100% 0%,color-mix(in srgb,var(--plugin-accent) 13%,transparent) 0%,color-mix(in srgb,var(--plugin-accent) 4%,transparent) 42%,transparent 72%)}.plugin-card .plugin-icon{background:color-mix(in srgb,var(--plugin-accent) 16%,transparent);color:var(--plugin-accent)}.plugin-watermark{position:absolute;top:-34px;right:-30px;width:148px;height:148px;display:block;color:var(--plugin-accent);opacity:.09;pointer-events:none;z-index:0}.plugin-card>.card-body,.plugin-card>.plugin-footer{position:relative;z-index:1}.plugin-card:has(.badge-secondary) .plugin-watermark{opacity:.05}@media(prefers-reduced-motion:no-preference){.plugin-card{transition:background-image .2s ease}}.plugin-card .card-body{flex:1}.plugin-header{display:flex;align-items:flex-start;gap:.75rem;margin-bottom:.75rem;flex-wrap:wrap}.plugin-icon{width:40px;height:40px;border-radius:8px;background:color-mix(in srgb,var(--dm-primary) 14%,transparent);color:var(--dm-primary, #5b8cff);display:flex;align-items:center;justify-content:center;flex-shrink:0}.plugin-meta{flex:1 1 140px;min-width:0}.plugin-name{font-weight:600;margin-bottom:.15rem}.plugin-version{font-size:.75rem;color:var(--dm-text-muted, #888)}.plugin-desc{font-size:.875rem;color:var(--dm-text-muted, #888);margin-bottom:1rem}.plugin-footer{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;border-top:1px solid var(--dm-border, rgba(255,255,255,.08))}.plugin-footer-actions{display:flex;align-items:center;gap:.5rem}#admin-sidebar .sidebar-link{position:relative!important}#admin-sidebar .sidebar-badge{position:absolute!important;right:.75rem!important;top:50%!important;transform:translateY(-50%)!important;margin-left:0!important;padding-left:.45rem!important;padding-right:.45rem!important}#admin-sidebar .sidebar-text{padding-right:2rem!important}#admin-sidebar.sidebar-dark{background:var(--dm-surface);border-color:var(--dm-border)}#admin-sidebar.sidebar-dark .sidebar-header{background:var(--dm-surface-raised);border-color:var(--dm-border)}#admin-sidebar.sidebar-dark .sidebar-header-title{color:var(--dm-text)}#admin-sidebar.sidebar-dark .sidebar-link{color:var(--dm-text-muted)}#admin-sidebar.sidebar-dark .sidebar-link:hover{color:var(--dm-text);background:var(--dm-surface-overlay)}#admin-sidebar.sidebar-dark .sidebar-link.active{color:var(--dm-primary);background:color-mix(in srgb,var(--dm-primary) 14%,transparent);border-left-color:var(--dm-primary)}#admin-sidebar.sidebar-dark .sidebar-heading{color:var(--dm-text-muted)}#admin-sidebar.sidebar-dark .sidebar-divider{background:var(--dm-border)}#admin-sidebar.sidebar-dark .sidebar-footer{background:var(--dm-surface-raised);border-color:var(--dm-border)}.sidebar-heading--collapsible{display:flex!important;align-items:center;justify-content:space-between;cursor:pointer;user-select:none}.sidebar-heading-toggle{display:flex;align-items:center;opacity:.5;flex-shrink:0;transition:transform .2s ease}.sidebar-heading--collapsible.is-collapsed .sidebar-heading-toggle{transform:rotate(-90deg)}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.p-0{padding:0!important}.text-muted{color:var(--dm-text-muted, #888)}@media(max-width:768px){.view-container{padding:1rem}.editor-body{flex-direction:column}.editor-divider{width:auto;height:1px;cursor:default;background:var(--dm-border, rgba(255,255,255,.08))}}.image-editor{display:flex;flex-direction:column;gap:0}.image-editor-toolbar{display:flex;flex-direction:row;align-items:center;gap:.25rem;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border, rgba(255, 255, 255, .1))}.image-editor-sep{display:inline-block;width:1px;height:1.5rem;background:var(--dm-border, rgba(255, 255, 255, .15));margin:0 .25rem;flex-shrink:0}.editor-toolbar-btn.active{color:var(--dm-primary, #7c6af7);background:color-mix(in srgb,var(--dm-primary, #7c6af7) 15%,transparent)}.editor-toolbar-caret{font-size:.6rem;line-height:1;margin-left:2px;opacity:.6}.editor-toolbar-dropdown{position:absolute;z-index:1000;background:var(--dm-surface, #1e1e2e);border:1px solid var(--dm-border, rgba(255, 255, 255, .1));border-radius:8px;box-shadow:0 8px 24px #0006;padding:4px;min-width:160px;max-height:70vh;overflow-y:auto}.editor-toolbar-dropdown-heading{padding:6px 10px 3px;color:var(--dm-text-muted, #8a8a9a);font-size:.66rem;text-transform:uppercase;letter-spacing:.06em}.editor-toolbar-dropdown-item{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:none;border:none;color:var(--dm-text, #ddd);padding:7px 10px;border-radius:5px;cursor:pointer;font-size:.85rem;transition:background .12s,color .12s}.editor-toolbar-dropdown-item:hover{background:var(--dm-hover-bg);color:var(--dm-text, #fff)}.editor-toolbar-dropdown-item span[data-icon],.editor-toolbar-dropdown-item svg{width:15px;height:15px;flex-shrink:0;opacity:.75}.editor-toolbar{position:relative}.editor-effects-dropdown-menu{position:absolute;top:100%;z-index:1000;background:var(--dm-surface, #1e1e2e);border:1px solid var(--dm-border, rgba(255, 255, 255, .1));border-radius:8px;box-shadow:0 8px 24px #0006;padding:6px;min-width:180px;max-height:340px;overflow-y:auto}.editor-effects-category{padding:6px 8px 3px;font-size:.7rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--dm-text-muted, #888)}.editor-effects-item{display:block;width:100%;text-align:left;background:none;border:none;color:var(--dm-text, #ddd);padding:6px 10px;border-radius:5px;cursor:pointer;font-size:.85rem;transition:background .12s,color .12s}.editor-effects-item:hover{background:var(--dm-hover-bg);color:var(--dm-text, #fff)}.editor-icon-picker{position:absolute;z-index:1000;background:var(--dm-surface, #1e1e2e);border:1px solid var(--dm-border, rgba(255, 255, 255, .1));border-radius:8px;box-shadow:0 8px 24px #0006;padding:8px;width:320px}.editor-icon-picker-search{width:100%;margin-bottom:8px;font-size:.8rem}.editor-spacer-picker{position:absolute;z-index:9999;background:var(--dm-surface, #1e1e2e);border:1px solid var(--dm-border, #333);border-radius:8px;padding:12px;width:240px;box-shadow:0 8px 24px #00000073}.editor-spacer-picker-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--dm-text-muted, #888);margin-bottom:10px}.editor-spacer-picker-row{display:flex;align-items:center;gap:10px;margin-bottom:12px}.editor-spacer-slider{flex:1;accent-color:var(--dm-primary, #6366f1);cursor:pointer}.editor-spacer-slider-value{font-size:13px;font-weight:600;min-width:38px;text-align:right;color:var(--dm-text, #cdd6f4)}.editor-spacer-insert-btn{width:100%}.editor-icon-picker-size-row{display:flex;align-items:center;gap:8px;padding:0 8px 8px}.editor-icon-picker-size-row label{font-size:11px;color:var(--dm-text-muted, #888);white-space:nowrap}.editor-icon-picker-size{width:70px;font-size:12px;padding:3px 6px}.editor-icon-picker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(52px,1fr));gap:2px;max-height:280px;overflow-y:auto}.editor-icon-picker-item{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:6px 2px;border-radius:5px;cursor:pointer;background:none;border:none;color:var(--dm-text, #ddd);font-size:.58rem;text-align:center;gap:4px;overflow:hidden;transition:background .12s,color .12s}.editor-icon-picker-item:hover{background:var(--dm-hover-bg);color:var(--dm-text, #fff)}.editor-icon-picker-item span[data-icon],.editor-icon-picker-item svg{width:16px;height:16px;flex-shrink:0}.editor-icon-picker-item>span:last-child{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.2}.editor-icon-picker-empty{grid-column:1 / -1;text-align:center;color:var(--dm-text-muted, #888);font-size:.8rem;padding:1.5rem}.image-editor-canvas{min-height:320px;max-height:520px;background:#111;overflow:hidden;display:flex;align-items:center;justify-content:center}.image-editor-canvas img{display:block;max-width:100%;max-height:520px}.image-editor-resize{display:flex;flex-direction:row;align-items:center;gap:.5rem;padding:.6rem .75rem;border-top:1px solid var(--dm-border, rgba(255, 255, 255, .1));font-size:.85rem}.image-editor-resize label{font-weight:600;color:var(--dm-text-muted, #888);font-size:.8rem}.image-editor-resize .form-input{width:5rem;padding:.25rem .5rem;font-size:.85rem}.image-editor-footer{display:flex;flex-direction:row;align-items:center;justify-content:space-between;padding:.75rem;border-top:1px solid var(--dm-border, rgba(255, 255, 255, .1));gap:.5rem}.image-editor-effects{border-top:1px solid var(--dm-border, rgba(255, 255, 255, .1))}.image-editor-tab-bar{display:flex;flex-direction:row;overflow-x:auto;border-bottom:1px solid var(--dm-border, rgba(255, 255, 255, .1));scrollbar-width:none}.image-editor-tab-bar::-webkit-scrollbar{display:none}.image-editor-tab-btn{display:inline-flex;align-items:center;gap:.35rem;padding:.5rem .85rem;font-size:.8rem;font-weight:500;border:none;border-bottom:2px solid transparent;background:transparent;cursor:pointer;color:var(--dm-text-muted, #888);white-space:nowrap;transition:color .15s,border-color .15s;flex-shrink:0}.image-editor-tab-btn:hover{color:var(--dm-text, #eee)}.image-editor-tab-btn.active{color:var(--dm-primary, #7c6af7);border-bottom-color:var(--dm-primary, #7c6af7)}.image-editor-tab-panel{padding:.75rem;max-height:200px;overflow-y:auto}.ie-presets-grid{display:flex;flex-wrap:wrap;gap:.35rem;margin-bottom:.5rem}.ie-preset-btn.active{color:var(--dm-primary, #7c6af7);background:color-mix(in srgb,var(--dm-primary, #7c6af7) 15%,transparent);border-color:var(--dm-primary, #7c6af7)}.ie-server-note{font-size:.73rem;color:var(--dm-text-muted, #888);font-style:italic;margin:.25rem 0 0;line-height:1.4}.ie-slider-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem}.ie-slider-label{width:6rem;font-size:.8rem;color:var(--dm-text-muted, #888);font-weight:600;flex-shrink:0}.ie-slider{flex:1;accent-color:var(--dm-primary, #7c6af7);cursor:pointer}.ie-slider-val{width:3rem;font-size:.8rem;text-align:right;color:var(--dm-text, #eee);flex-shrink:0}.ie-select{flex:1;font-size:.85rem;padding:.25rem .5rem}.ie-colour-input{width:40px;height:28px;padding:0;border:1px solid var(--dm-border, rgba(255, 255, 255, .15));border-radius:4px;cursor:pointer;background:transparent;flex-shrink:0}.ie-wm-preview-row{display:flex;align-items:center;gap:.5rem;margin:.4rem 0}.ie-wm-preview-img{width:48px;height:48px;object-fit:contain;border-radius:4px;background:var(--dm-surface-raised, rgba(255, 255, 255, .06));flex-shrink:0}.ie-wm-preview-name{font-size:.8rem;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dm-text, #eee)}.ie-media-picker{padding:.25rem 0}.ie-media-picker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:.5rem;max-height:300px;overflow-y:auto}.ie-media-thumb{display:flex;flex-direction:column;align-items:center;gap:.25rem;padding:.4rem;border-radius:6px;cursor:pointer;border:2px solid transparent;transition:border-color .15s,background .15s}.ie-media-thumb:hover{background:var(--dm-surface-raised, rgba(255, 255, 255, .08));border-color:var(--dm-primary, #7c6af7)}.ie-media-thumb img{width:72px;height:72px;object-fit:cover;border-radius:4px}.ie-media-thumb-name{font-size:.7rem;color:var(--dm-text-muted, #888);text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:72px}.dm-slideover-left,.dm-slideover-right{width:min(40rem,95vw)!important;min-width:min(36rem,100vw)!important;max-width:100vw!important}.dm-slideover-resizer{position:absolute;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2;background:transparent}.dm-slideover-right>.dm-slideover-resizer{left:0}.dm-slideover-left>.dm-slideover-resizer{right:0}.dm-slideover-resizer:hover,.is-resizing>.dm-slideover-resizer{background:var(--dm-primary);opacity:.7}.dm-slideover.is-resizing{user-select:none;transition:none!important}.dm-slideover-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid var(--dm-border, #333);flex-shrink:0}.dm-slideover-title{margin:0;font-size:1rem;font-weight:600}.dm-slideover-close{padding:.2rem .35rem!important;line-height:1;font-size:.75rem}.dm-slideover-body{flex:1;overflow:auto;overscroll-behavior:contain;min-height:0;padding:1.5rem!important;box-sizing:border-box}.dm-slideover-body:empty{flex:0 0 auto;padding:0!important}.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-body):not(.dm-slideover-close){padding:1.5rem!important;box-sizing:border-box;flex:1 1 auto;min-height:0;overflow:auto;overscroll-behavior:contain}.dm-slideover-body,.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-close){scrollbar-width:thin;scrollbar-color:var(--dm-border) transparent}.dm-slideover-body::-webkit-scrollbar,.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-close)::-webkit-scrollbar{width:8px;height:8px}.dm-slideover-body::-webkit-scrollbar-track,.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-close)::-webkit-scrollbar-track{background:transparent}.dm-slideover-body::-webkit-scrollbar-thumb,.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-close)::-webkit-scrollbar-thumb{background:var(--dm-border);border-radius:4px}.dm-slideover-body::-webkit-scrollbar-thumb:hover,.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-close)::-webkit-scrollbar-thumb:hover{background:var(--dm-text-muted)}.dm-slideover-body>div{padding-left:0!important;padding-right:0!important}.dm-slideover-body>button.btn:last-child{margin-top:.5rem}.dconfig-textarea{font-family:monospace;min-height:100px;resize:vertical}.dm-editor-line-numbers{white-space:pre}.dm-code-inline{font-family:Fira Code,Courier New,monospace;font-size:.82em;padding:.15em .35em;border-radius:3px;background:var(--dm-background-alt, rgba(0, 0, 0, .18));color:var(--dm-text, inherit);border:1px solid var(--dm-border, rgba(255, 255, 255, .08))}.dm-code-block{font-family:Fira Code,Courier New,monospace;font-size:.82rem;line-height:1.6;padding:.65rem .9rem;border-radius:6px;background:var(--dm-background-alt, rgba(0, 0, 0, .18));color:var(--dm-text, inherit);border:1px solid var(--dm-border, rgba(255, 255, 255, .08));white-space:pre;overflow-x:auto;display:block;margin:.4rem 0}.tabs-centered{text-align:center}.tabs-centered .tab-list{display:inline-flex}.tabs-centered .tab-content{text-align:left}@media(max-width:768px){.view-header{flex-direction:column;align-items:flex-start}.theme-grid{grid-template-columns:repeat(2,1fr)}.nav-item-row{flex-wrap:wrap}.nav-col-icon,.nav-col-parent{display:none}#admin-topbar{padding:0 .75rem}.topbar-brand-text{display:none}.image-editor-toolbar{flex-wrap:wrap}}#topbar-bell{position:relative}.topbar-action-link[hidden],.topbar-bell-badge[hidden]{display:none}.topbar-bell-badge{position:absolute;top:2px;right:2px;min-width:16px;height:16px;padding:0 4px;background:var(--dm-danger, #dc2626);color:var(--dm-danger-text, #fff);font-size:10px;font-weight:700;line-height:16px;border-radius:9999px;text-align:center;pointer-events:none}.cb-scaffolder{padding:1rem 1.1rem;background:var(--dm-background-alt);border:1px solid var(--dm-primary);border-radius:8px;color:var(--dm-text)}.cb-scaffolder-head{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}.cb-scaffolder-head .cb-icon{flex-shrink:0;color:var(--dm-primary)}.cb-scaffolder-head h2{margin:0;font-size:1.05rem;font-weight:700;color:var(--dm-text)}.cb-scaffolder-blurb{margin:0 0 .75rem;font-size:.88rem;line-height:1.5;color:var(--dm-text-muted)}.cb-recipe-list{display:flex;flex-direction:column;gap:.5rem}.cb-recipe-card{display:flex;align-items:flex-start;gap:.75rem;text-align:left;padding:.75rem 1rem;background:var(--dm-surface);border:1px solid var(--dm-border);border-radius:6px;cursor:pointer;transition:border-color .12s;width:100%;color:var(--dm-text)}.cb-recipe-card:hover{border-color:var(--dm-primary)}.cb-recipe-card .cb-icon{flex-shrink:0;font-size:1.5rem;color:var(--dm-primary);margin-top:.1rem}.cb-recipe-meta{flex:1}.cb-recipe-name{font-weight:600;margin-bottom:.15rem;font-size:.95rem;color:var(--dm-text)}.cb-recipe-desc{font-size:.83rem;line-height:1.45;color:var(--dm-text-muted)}.cb-apply-panel{padding:1rem 1.1rem;background:var(--dm-surface);border:1px solid var(--dm-primary);border-radius:8px;color:var(--dm-text)}.cb-apply-head{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}.cb-apply-head .cb-icon{color:var(--dm-primary);font-size:1.4rem}.cb-apply-head h3{margin:0;font-size:1rem;font-weight:600;color:var(--dm-text)}.cb-apply-desc,.cb-apply-hint{margin:.25rem 0 .75rem;font-size:.85rem;line-height:1.5;color:var(--dm-text-muted)}.cb-apply-hint{font-size:.75rem;margin-top:.2rem}.cb-apply-field{margin-bottom:.6rem}.cb-apply-field label{display:block;font-size:.82rem;font-weight:600;margin-bottom:.2rem;color:var(--dm-text)}.cb-apply-field input{width:100%;padding:.4rem .55rem;border:1px solid var(--dm-border);border-radius:4px;font-size:.88rem;background:var(--dm-surface);color:var(--dm-text)}.cb-apply-status{margin:.5rem 0;font-size:.85rem;min-height:1.2rem;color:var(--dm-text)}.cb-apply-status.cb-error{color:var(--dm-danger, #dc2626)}.cb-apply-buttons{display:flex;gap:.5rem;justify-content:flex-end;margin-top:.5rem}.cb-success-panel{padding:1rem 1.1rem;background:var(--dm-surface);border:1px solid var(--dm-success, #16a34a);border-radius:8px;color:var(--dm-text)}.cb-success-panel .cb-icon{color:var(--dm-success, #16a34a);font-size:1.4rem}.cb-success-summary{margin:.25rem 0 .5rem;padding-left:1.2rem;font-size:.86rem;line-height:1.6;color:var(--dm-text)}.cb-success-warning{margin:.5rem 0;padding:.5rem .75rem;background:var(--dm-background-alt);border-left:3px solid var(--dm-warning, #eab308);border-radius:0 4px 4px 0;font-size:.82rem;color:var(--dm-text-muted)}.cb-success-snippet-label{margin:0 0 .25rem;font-size:.82rem;font-weight:600;color:var(--dm-text)}.cb-success-actions{display:flex;gap:.5rem;margin-top:.5rem;flex-wrap:wrap}.cb-code{position:relative}.cb-code pre{background:var(--dm-background-alt);color:var(--dm-text);display:block;padding:.6rem 2.4rem .6rem .75rem;white-space:pre;overflow-x:auto;margin:0;border-radius:6px;font-size:.82em;font-family:monospace;line-height:1.45}.cb-code .cb-copy{position:absolute;top:.35rem;right:.35rem;background:none;border:none;cursor:pointer;opacity:.45;padding:.2rem;line-height:1;color:var(--dm-text)}.cb-code .cb-copy:hover{opacity:1}.cb-intro{padding:.75rem 1rem;border-left:3px solid var(--dm-primary);background:var(--dm-background-alt);border-radius:0 6px 6px 0}.cb-intro p{margin:0;font-size:.92rem;line-height:1.5;color:var(--dm-text)}.cb-tutorial-link{display:flex;align-items:center;gap:.6rem;padding:.65rem .85rem;border:1px solid var(--dm-border);border-radius:6px;text-decoration:none;color:var(--dm-text);font-size:.88rem;transition:border-color .12s}.cb-tutorial-link:hover{border-color:var(--dm-primary)}.cb-tutorial-link .cb-icon{flex-shrink:0;color:var(--dm-primary)}.cb-tutorial-link .cb-arrow{color:var(--dm-text-muted);font-size:1.1rem;margin-left:auto}.menu-tree-row{display:grid;grid-template-columns:16px 1.4fr 1.4fr .9fr .7fr .9fr repeat(6,auto);gap:6px;align-items:center;padding:4px 0;transition:opacity .15s ease}.menu-tree-row--hidden{opacity:.5}.menu-tree-row--tool .me-tool-badge{margin-left:.4rem;font-size:.7rem;vertical-align:middle}.menu-tree-row--default .me-row-label{font-style:italic;opacity:.8}.menu-tree-grip{cursor:grab;color:var(--dm-text-muted)}.menu-tree{padding:8px 0}.menu-tree-row--separator{display:grid;grid-template-columns:16px minmax(80px,1fr) repeat(7,auto);gap:6px;align-items:center;padding:2px 0;opacity:.85}.menu-tree-row--separator:hover{opacity:1}.menu-tree-row--separator .me-sep-line{display:flex;align-items:center;gap:10px;width:100%}.menu-tree-row--separator .me-sep-line:before,.menu-tree-row--separator .me-sep-line:after{content:"";flex:1;border-top:1px dashed var(--dm-border, rgba(128, 128, 128, .55))}.menu-tree-row--separator .me-sep-label{font-size:.66rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--dm-text-muted, #8a8a8a);white-space:nowrap}.dm-admin-sidebar .sidebar-divider{border:0;border-top:1px solid var(--dm-border, rgba(128, 128, 128, .5));margin:.5rem .75rem;opacity:.8}.dm-admin-sidebar .sidebar-spacer--xs{height:var(--dm-space-1, .25rem)}.dm-admin-sidebar .sidebar-spacer--sm{height:var(--dm-space-2, .5rem)}.dm-admin-sidebar .sidebar-spacer--md{height:var(--dm-space-4, 1rem)}.dm-admin-sidebar .sidebar-spacer--lg{height:var(--dm-space-6, 1.5rem)}.dm-admin-sidebar .sidebar-spacer--xl{height:var(--dm-space-10, 2.5rem)}.dm-admin-sidebar .sidebar-spacer--flex{flex:1 1 auto;min-height:var(--dm-space-4, 1rem)}.dm-admin-sidebar--has-flex-spacer{display:flex;flex-direction:column;min-height:100%}.menu-tree-row--spacer{display:grid;grid-template-columns:16px minmax(80px,1fr) repeat(7,auto);gap:6px;align-items:center;padding:2px 0;opacity:.85}.menu-tree-row--spacer:hover{opacity:1}.menu-tree-row--spacer .me-spacer-line{display:flex;align-items:center;gap:8px;width:100%}.menu-tree-row--spacer .me-spacer-line:before,.menu-tree-row--spacer .me-spacer-line:after{content:"";flex:1;border-top:1px dotted var(--dm-border, rgba(128, 128, 128, .55))}.menu-tree-row--spacer .me-spacer-label{font-size:.66rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--dm-text-muted, #8a8a8a);white-space:nowrap}.menu-tree-row--spacer .me-spacer-size{width:auto;min-width:5.5rem;height:26px;padding:0 .35rem;font-size:.75rem}.menu-tree-row .me-icon-wrap{display:flex;gap:2px}.menu-tree-row .me-icon-wrap .me-icon{flex:1;min-width:0}.menu-tree-row .me-icon-wrap .me-icon-pick{flex-shrink:0;padding:.2rem .45rem;font-size:.85em}.dm-admin-sidebar{display:flex;flex-direction:column;gap:2px;padding:8px 0;font-size:.875rem}.dm-admin-sidebar details{margin:0}.dm-admin-sidebar details>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:.5rem;padding:.4rem .75rem;border-radius:4px;user-select:none;font-size:.78rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--dm-text-muted, #888)}.dm-admin-sidebar details details>summary{font-size:.875rem;font-weight:500;text-transform:none;letter-spacing:0;color:inherit}.dm-admin-sidebar .dm-menu-badge{font-size:.7rem;font-weight:600}.dm-admin-sidebar details>summary::-webkit-details-marker{display:none}.dm-admin-sidebar details>summary:after{content:"\25b8";margin-left:auto;transition:transform .15s ease;color:var(--dm-text-muted, #999);font-size:.85em}.dm-admin-sidebar details[open]>summary:after{transform:rotate(90deg)}.dm-admin-sidebar details>div{padding-left:1rem;border-left:1px solid var(--dm-border-light, rgba(0, 0, 0, .08));margin-left:.95rem}.dm-admin-sidebar .sidebar-link{display:flex;align-items:center;gap:.5rem;padding:.35rem .75rem;text-decoration:none;color:var(--dm-text, inherit);border-radius:4px}.dm-admin-sidebar .sidebar-link:hover,.dm-admin-sidebar details>summary:hover{background:var(--dm-hover-bg, rgba(0, 0, 0, .05))}.dm-admin-sidebar .sidebar-link.active{background:var(--dm-active-bg, rgba(0, 0, 0, .08));font-weight:600}.dm-admin-sidebar .sidebar-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dm-admin-sidebar [data-icon]{flex-shrink:0}.dm-admin-sidebar-header{position:sticky;top:0;z-index:2;background:var(--dm-surface);display:flex;align-items:center;gap:.5rem;padding:.65rem .85rem;margin:0 0 .5rem;font-weight:600;font-size:.95rem;letter-spacing:.01em;color:var(--dm-text, inherit);border-bottom:1px solid var(--dm-border-light, rgba(0, 0, 0, .08))}.dm-admin-sidebar--dark .dm-admin-sidebar-header{background:#1a1d24}.dm-admin-sidebar--light .dm-admin-sidebar-header{background:#fff}.dm-admin-sidebar-header [data-icon]{color:var(--dm-text-muted, #999)}.dm-admin-sidebar .sidebar-badge{margin-left:auto;padding:0 .4rem;min-width:1.25rem;height:1.1rem;line-height:1.1rem;text-align:center;font-size:.7rem;font-weight:600;border-radius:999px;background:var(--dm-active-bg, rgba(0, 0, 0, .12));color:var(--dm-text-muted, #888)}.dm-admin-sidebar .sidebar-link.active .sidebar-badge{background:var(--dm-primary, #4a86e8);color:var(--dm-primary-text, #fff)}.dm-admin-sidebar .sidebar-children{overflow:hidden;transition:max-height .22s ease,opacity .18s ease}.dm-admin-sidebar details:not([open])>.sidebar-children{opacity:0}.dm-admin-sidebar{position:relative}.dm-admin-sidebar-handle{position:absolute;top:0;right:0;width:6px;height:100%;cursor:col-resize;background:transparent;z-index:10;transition:background .15s}.dm-admin-sidebar-handle:hover,.dm-admin-sidebar-handle:active{background:var(--dm-primary, #4a86e8);opacity:.35}.dm-admin-sidebar--dark{background:#1a1d24;color:#e3e6eb}.dm-admin-sidebar--dark .sidebar-link,.dm-admin-sidebar--dark summary{color:#e3e6eb}.dm-admin-sidebar--dark .sidebar-link:hover,.dm-admin-sidebar--dark details>summary:hover{background:#ffffff0f}.dm-admin-sidebar--dark .sidebar-link.active{background:#4a86e840}.dm-admin-sidebar--dark .sidebar-badge{background:#ffffff1f;color:#cbd0d8}.dm-admin-sidebar--light{background:#fff;color:#1a1d24;border-right:1px solid rgba(0,0,0,.08)}.dm-admin-sidebar--light .sidebar-link,.dm-admin-sidebar--light summary{color:#1a1d24}.dm-admin-sidebar--light .sidebar-link:hover,.dm-admin-sidebar--light details>summary:hover{background:#0000000a}.dm-admin-sidebar--light .sidebar-link.active{background:#4a86e826}.dm-admin-sidebar--transparent{background:transparent}.page-header{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin:0 0 1.25rem;padding-bottom:.75rem;border-bottom:1px solid var(--dm-border-light, rgba(0, 0, 0, .08))}.page-header h2{margin:0;display:flex;align-items:center;gap:.5rem;font-size:1.4rem;line-height:1.2}.page-header h2 [data-icon]{color:var(--dm-text-muted, #888)}.header-actions{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.card+.card,.card+p,p+.card,.card+.toolbar,.toolbar+.card,.card+.grid{margin-top:1rem}.toolbar{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:0 0 1rem}.btn+.btn,.btn+a.btn,a.btn+.btn,a.btn+a.btn{margin-left:.35rem}[style*=inline-flex] .btn+.btn,[style*=inline-flex] .btn+a.btn,[style*=inline-flex] a.btn+.btn{margin-left:0}.card-body>[id$=-form],.card-body>form{margin:.25rem 0}.card-header{display:flex;align-items:center;gap:.5rem}.card-header h3{margin:0;font-size:1.05rem}.card-header [data-icon]{color:var(--dm-text-muted, #888)}.page-header+p.text-muted{margin:-.5rem 0 1rem}.dm-pe-grid{gap:.75rem 1rem}.dm-pe-grid .form-group{display:flex;flex-direction:column;gap:.25rem;margin:0}.dm-pe-grid .form-group.col-span-2{grid-column:1 / -1}.dm-pe-grid label{font-size:.85rem;font-weight:600;color:var(--dm-text, inherit)}.dm-pe-grid label .required{color:var(--dm-danger, #c33);margin-left:2px}.dm-pe-grid .form-hint{font-size:.75rem;color:var(--dm-text-muted, #888);margin-top:.1rem}.dm-pe-grid .form-input{width:100%;padding:.4rem .6rem}.dm-pe-grid textarea.form-input{resize:vertical;min-height:2.5rem}.dm-menu-badge{display:inline-block;margin-left:6px;padding:1px 7px;font-size:.7em;font-weight:700;line-height:1.4;border-radius:999px;background:var(--dm-text-muted);color:var(--dm-text-inverse, #fff);vertical-align:middle}.menu-tree-row--slim{display:grid;grid-template-columns:16px 1fr auto repeat(9,auto);gap:6px;align-items:center}.menu-tree-row--slim .me-row-label{font-weight:600}.menu-tree-row--slim .me-row-url{color:var(--dm-text-muted);font-size:.85em}.menu-tree-row.me-drop-above{box-shadow:inset 0 2px 0 0 var(--dm-primary)}.menu-tree-row.me-drop-below{box-shadow:inset 0 -2px 0 0 var(--dm-primary)}.menu-tree-row.me-drop-into{background:color-mix(in srgb,var(--dm-primary) 12%,transparent);box-shadow:inset 0 0 0 1px var(--dm-primary);border-radius:var(--dm-radius-sm, 4px)}.menu-tree-row[draggable=true] .menu-tree-grip{cursor:grabbing}.me-pages-picker{display:flex;gap:6px;align-items:center;margin-bottom:6px}.me-pages-picker .form-input{flex:1;min-width:0}.me-colour-control{display:flex;gap:6px;align-items:center}.me-colour-control .me-colour-hex{width:48px;height:32px;padding:2px;flex:none}.me-bind-summary{padding:.6rem .8rem;border:1px solid var(--dm-border, rgba(128, 128, 128, .35));border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-text) 4%,transparent);font-size:.9em}.me-bind-summary code{font-size:.9em}.me-item-form{display:flex;flex-direction:column;gap:10px;padding:4px 2px}.me-item-form .form-field{display:flex;flex-direction:column;gap:4px}.me-item-form .form-label{font-size:.85em;color:var(--dm-text-muted)}.me-form-section{margin:12px 0 2px;padding-top:10px;border-top:1px solid var(--dm-border-light, rgba(0,0,0,.15));font-size:.78em;text-transform:uppercase;letter-spacing:.06em;color:var(--dm-text-muted)}.me-item-form .me-form-section:first-child{margin-top:0;padding-top:0;border-top:0}.me-item-form .me-colour-hex{width:48px;height:32px;padding:2px}.me-item-form .me-field-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.me-item-form .me-field-grid .form-field{margin:0}.me-item-form .me-icon-field{display:flex;align-items:center;gap:8px}.me-item-form .me-icon-field .form-input{flex:1}.me-item-form .me-icon-preview{width:24px;height:24px;flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;color:var(--dm-text-muted)}#admin-sidebar.dm-menu-floating{border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 8px);box-shadow:var(--dm-shadow-lg);overflow:hidden}@media(max-width:768px){#admin-sidebar.dm-menu-floating{position:static!important;inset:auto!important;max-height:none!important;transform:none!important}.dashboard-wrapper{padding-left:0!important;padding-right:0!important}}[data-logic-section]{border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 6px);padding:.9rem 1rem 1rem;margin-bottom:1rem;background:color-mix(in srgb,var(--dm-text) 3%,transparent)}[data-logic-section] .fb-logic-cond-row,[data-logic-section] .fb-logic-val-rule{padding:.55rem .65rem;border-radius:var(--dm-radius-sm, 4px);border:1px solid var(--dm-border);background:var(--dm-surface, transparent)}[data-logic-section] .fb-logic-cond-row:last-of-type,[data-logic-section] .fb-logic-val-rule:last-of-type{margin-bottom:.75rem}[data-logic-section]>.btn{margin-top:.15rem}[data-logic-section] textarea{min-height:5rem}.project-filter-notice{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap;margin:0 0 1rem;padding:.6rem .85rem;border:1px solid var(--dm-border);border-left:3px solid var(--dm-info, var(--dm-primary));border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-info, var(--dm-primary)) 6%,transparent);font-size:.85rem;color:var(--dm-text-muted)}.project-filter-notice a{color:var(--dm-primary);font-weight:600}.ctx-help{display:inline-flex;align-items:center;justify-content:center;width:1.05rem;height:1.05rem;margin-left:.35rem;padding:0;border:1px solid var(--dm-border-dark, var(--dm-border));border-radius:var(--dm-radius-full, 9999px);background:transparent;color:var(--dm-text-muted);font-size:.68rem;font-weight:700;line-height:1;cursor:pointer;vertical-align:middle;flex-shrink:0}.ctx-help:hover,.ctx-help:focus-visible{border-color:var(--dm-primary);color:var(--dm-primary);outline:none}.card-header h3 .ctx-help{align-self:center}.ctx-preview-card{position:sticky;top:1rem}@media(max-width:1100px){.ctx-workspace>.col-8,.ctx-workspace>.col-4{flex:0 0 auto;width:100%}.ctx-preview-card{position:static}}.dm-plugin-shell{border:1px solid var(--dm-border);border-radius:var(--dm-radius-lg, 12px);background:var(--dm-surface);overflow:hidden}.dm-plugin-banner{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;padding:.7rem 1rem;background:var(--dm-background-alt);border-bottom:1px solid var(--dm-border)}.dm-plugin-banner-identity{display:flex;align-items:center;gap:.55rem;min-width:0;margin-right:auto}.dm-plugin-banner-actions{display:flex;align-items:center;gap:.25rem;flex:0 0 auto;margin-right:.15rem;padding-right:.4rem;border-right:1px solid var(--dm-border)}.dm-plugin-banner-action{padding:.25rem .4rem;line-height:1;color:var(--dm-text-muted)}.dm-plugin-banner-action:hover{color:var(--dm-primary)}.dm-plugin-banner-icon{display:inline-flex;align-items:center;color:var(--dm-primary);flex:0 0 auto}.dm-plugin-banner-name{margin:0;font-size:1.05rem;font-weight:600;letter-spacing:.01em;color:var(--dm-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dm-activity{display:inline-flex;align-items:center;flex:0 0 auto;margin-left:.35rem}.dm-activity domma-loader{display:inline-block;line-height:0}.dm-activity[hidden]{display:none!important}#admin-topbar>.dm-activity{margin:0 .75rem 0 0}.dm-plugin-banner-parent{font-weight:500;color:var(--dm-text-muted)}.dm-plugin-banner-sep{margin:0 .4rem;color:var(--dm-text-muted)}.dm-plugin-banner-facts{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}.dm-plugin-fact{font-size:.72rem;line-height:1.6;padding:1px 8px;border-radius:999px;border:1px solid var(--dm-border);color:var(--dm-text-muted);background:var(--dm-surface);white-space:nowrap}.dm-plugin-fact--version{font-variant-numeric:tabular-nums;color:var(--dm-text)}.dm-plugin-fact--licence.is-included{color:var(--dm-info);border-color:var(--dm-info)}.dm-plugin-fact--licence.is-licensed{color:var(--dm-success);border-color:var(--dm-success)}.dm-plugin-fact--expiry{font-variant-numeric:tabular-nums}.dm-plugin-fact--expiry.is-warning{color:var(--dm-warning);border-color:var(--dm-warning)}.dm-plugin-fact--expiry.is-danger{color:var(--dm-danger);border-color:var(--dm-danger)}.dm-plugin-body{padding:1rem}.dm-plugin-body .card.dm-plugin-unwrap{border:0;background:none;box-shadow:none;margin:0}.dm-plugin-body .card.dm-plugin-unwrap>.card-body{padding:0}.dm-plugin-body>.p-4{padding:0}@media(max-width:640px){.dm-plugin-banner{align-items:flex-start;flex-direction:column;gap:.5rem}.dm-plugin-body{padding:.75rem}}.analytics-filter{max-width:240px}.dm-help-btn{display:inline-flex;align-items:center;justify-content:center;vertical-align:middle;margin-left:.3rem;padding:0;border:0;background:none;line-height:1;color:var(--dm-text-muted);opacity:.75;cursor:pointer}.dm-help-btn:hover,.dm-help-btn:focus-visible{color:var(--dm-primary);opacity:1}.dm-help-pop{position:fixed;z-index:10050;max-width:300px;padding:.6rem .75rem;background:var(--dm-surface, var(--dm-background));color:var(--dm-text);border:1px solid var(--dm-border);border-radius:var(--dm-radius-md);box-shadow:0 8px 24px #00000040;font-size:.85rem;line-height:1.45;text-align:left;font-weight:400}.dm-help-pop strong{display:block;margin-bottom:.2rem;font-weight:600}.dm-help-pop p{margin:0}.dm-admin-sidebar .sidebar-badge[data-badge-label]{cursor:help}.dm-admin-sidebar .sidebar-badge--danger,.dm-admin-sidebar .sidebar-link.active .sidebar-badge--danger{background:var(--dm-danger, #dc2626);color:#fff}.dm-admin-sidebar .sidebar-badge--live{background:color-mix(in srgb,var(--dm-success, #16a34a) 20%,transparent);color:var(--dm-success, #16a34a)}#admin-sidebar .sidebar-badge--live{padding-left:1.05rem!important}#admin-sidebar summary>.sidebar-badge--sum{position:static!important;transform:none!important;margin-left:auto!important;margin-right:.45rem;text-transform:none;letter-spacing:0}#admin-sidebar summary:has(>.sidebar-badge--sum):after{margin-left:0}#admin-sidebar details[open]>summary>.sidebar-badge--sum{display:none}.dm-admin-sidebar .sidebar-badge--live:before{content:"";position:absolute;left:.4rem;top:50%;width:.4rem;height:.4rem;margin-top:-.2rem;border-radius:50%;background:currentColor;animation:dm-badge-pulse 2s ease-in-out infinite}@keyframes dm-badge-pulse{50%{opacity:.35}}@media(prefers-reduced-motion:reduce){.dm-admin-sidebar .sidebar-badge--live:before{animation:none}}.dm-badge-pop{width:290px;max-width:calc(100vw - 16px);font-size:.8rem;pointer-events:none}.dm-badge-pop-title{display:block;font-size:.85rem}.dm-badge-pop-sub{margin:.1rem 0 .5rem;color:var(--dm-text-muted)}.dm-badge-pop-rows{display:grid;grid-template-columns:1fr auto;gap:.2rem .75rem;margin:0 0 .5rem;padding:.45rem 0;border-top:1px solid var(--dm-border);border-bottom:1px solid var(--dm-border)}.dm-badge-pop-rows dt{color:var(--dm-text-muted)}.dm-badge-pop-rows dd{margin:0;text-align:right;font-weight:600;font-variant-numeric:tabular-nums}.dm-badge-pop-items{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.4rem}.dm-badge-pop-items li{display:flex;flex-direction:column;min-width:0}.dm-badge-pop-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dm-badge-pop-meta{font-size:.72rem;color:var(--dm-text-muted)}.dm-badge-pop .is-danger,.dm-badge-pop li.is-danger .dm-badge-pop-meta{color:var(--dm-danger, #dc2626)}.dm-badge-pop-empty{margin:0;color:var(--dm-text-muted)}.dm-plugin-body,.dm-slideover{font-family:var(--dm-font-sans, inherit)}.dm-plugin-body :is(button,input,select,textarea,.btn,.form-input,.form-select,.form-textarea),.dm-slideover :is(button,input,select,textarea,.btn,.form-input,.form-select,.form-textarea){font-family:inherit}.dm-plugin-body :is(input,select,textarea,.form-input,.form-select,.form-textarea):not(.form-input-lg),.dm-slideover :is(input,select,textarea,.form-input,.form-select,.form-textarea):not(.form-input-lg){font-size:var(--dm-text-sm)}.dm-plugin-body .form-input-lg{font-size:var(--dm-text-base)}.dm-plugin-body :is(.font-mono,code,pre,kbd),.dm-slideover :is(.font-mono,code,pre,kbd){font-family:var(--dm-font-mono, monospace)}.dm-plugin-body .form-label,.dm-slideover .form-label{font-size:var(--dm-text-sm)}.related-box{display:flex;flex-direction:column;gap:.6rem;border-top:1px solid var(--dm-border);padding-top:.75rem}.related-box[hidden]{display:none}.related-head{display:flex;align-items:center;gap:.4rem}.related-head strong{flex:1}.related-list{list-style:none;margin:.3rem 0 0;padding:0;display:flex;flex-direction:column;gap:.25rem}.related-list li{display:flex;flex-direction:column}.related-list li.is-past{opacity:.7}.related-meta,.related-note,.related-loading{color:var(--dm-text-muted);font-size:var(--dm-text-xs);margin:0}.login-challenge{display:flex;flex-direction:column;gap:.6rem;margin-bottom:.75rem}.login-challenge #challenge-code{font-size:1.25rem;letter-spacing:.2em;text-align:center}.login-trust{display:flex;align-items:center;gap:.45rem;font-size:.875rem}.login-enrol{display:flex;flex-direction:column;align-items:center;gap:.6rem;margin-bottom:1rem}.login-enrol img{background:#fff;padding:.5rem;border-radius:.5rem}.login-secret{display:flex;flex-direction:column;align-items:center;gap:.25rem;text-align:center}.login-secret code{font-size:.95rem;letter-spacing:.08em;word-break:break-all}.usx-secret{display:flex;flex-direction:column;gap:.75rem;padding:1rem}.usx-secret-link{display:block;padding:.6rem .75rem;border:1px solid var(--dm-border);border-radius:var(--dm-radius-sm);background:var(--dm-background-alt);word-break:break-all;font-size:.8rem;user-select:all}.usx-secret-actions{display:flex;align-items:center;justify-content:flex-end;gap:.5rem}.usx-secret-actions .btn{display:inline-flex;align-items:center;gap:.35rem}.usx-secret-note{margin-right:auto;color:var(--dm-success)}.usx-secret-foot{color:var(--dm-text-muted);font-size:.8rem;margin:0}.uex-reset{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .5rem;margin-top:.5rem}.uex-reset>small:first-child{flex-basis:100%;color:var(--dm-text-muted)}.uex-reset .btn{display:inline-flex;align-items:center;gap:.35rem}.uex-reset-open{flex-basis:100%;color:var(--dm-info)}
|
|
1
|
+
.dashboard-layout{display:flex;flex-direction:column;height:100vh;min-height:100vh;overflow:hidden}.dashboard-wrapper{display:flex;flex:1;min-height:0;overflow:hidden}.dashboard-main{flex:1;min-width:0;overflow-y:auto}#admin-sidebar.dashboard-sidebar{display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}#admin-sidebar .dm-admin-sidebar{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--dm-border) transparent}#admin-sidebar .dm-admin-sidebar::-webkit-scrollbar{width:8px}#admin-sidebar .dm-admin-sidebar::-webkit-scrollbar-track{background:transparent}#admin-sidebar .dm-admin-sidebar::-webkit-scrollbar-thumb{background:var(--dm-border);border-radius:4px}#admin-sidebar .dm-admin-sidebar::-webkit-scrollbar-thumb:hover{background:var(--dm-text-muted)}.view-container{padding:1.5rem 2rem;max-width:1200px}.view-container:has(>.dm-plugin-shell){max-width:100%}.view-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem}.view-header h1{font-size:1.5rem;font-weight:700;margin:0;display:flex;align-items:center;gap:.5rem}.view-header-actions{display:flex;gap:.75rem;flex-wrap:wrap}.view-header-actions .btn-primary,.view-header-actions .btn-success,.view-header-actions .btn-danger{margin-left:.5rem}.stat-card .card-body{display:flex;align-items:center;gap:1rem}.stat-icon{width:44px;height:44px;border-radius:8px;background:color-mix(in srgb,var(--dm-primary) 14%,transparent);color:var(--dm-primary, #5b8cff);display:flex;align-items:center;justify-content:center;font-size:1.25rem;flex-shrink:0}.stat-icon--success{background:color-mix(in srgb,var(--dm-success) 15%,transparent);color:var(--dm-success)}.stat-icon--warning{background:color-mix(in srgb,var(--dm-warning) 15%,transparent);color:var(--dm-warning)}.stat-value{font-size:1.75rem;font-weight:700;line-height:1}.stat-label{font-size:.8rem;color:var(--dm-text-muted, #888);margin-top:.25rem}.toolbar{display:flex;align-items:center;gap:.75rem}.form-check-label{display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:.9rem}.form-hint{display:block;font-size:.8rem;color:var(--dm-text-muted, #888);margin-top:.25rem}.dashboard-main:has(#editor-meta-tabs){overflow:hidden}.view-container:has(#editor-meta-tabs){display:flex;flex-direction:column;height:calc(100dvh - 60px);max-width:100%;padding-bottom:0}.view-container:has(#block-editor-body){max-width:100%}.view-container:has(#ep-editor-tabs){max-width:100%}.ep-editor-grid{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr) minmax(0,1.3fr);gap:1rem;align-items:start}.ep-editor-grid>.ep-tryit-col,.ep-editor-grid>.ep-results-col{position:sticky;top:1rem;display:flex;flex-direction:column;gap:1rem;min-width:0}@media(max-width:1500px){.ep-editor-grid{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.ep-editor-grid>:first-child{grid-column:1 / -1}.ep-editor-grid>.ep-tryit-col,.ep-editor-grid>.ep-results-col{position:static}}@media(max-width:1024px){.ep-editor-grid{grid-template-columns:minmax(0,1fr)}}#editor-meta-tabs{flex:1;min-height:0;display:flex;flex-direction:column}#editor-meta-tabs .tab-content{flex:1;min-height:0;overflow:hidden}#editor-meta-tabs .tab-panel{height:100%;overflow-y:auto}#editor-meta-tabs .tab-panel:has(.editor-card),#live-preview-panel{overflow:hidden}.live-preview-bar{display:flex;align-items:center;gap:var(--dm-space-4);flex-wrap:wrap;padding:var(--dm-space-2) 0;border-bottom:1px solid var(--dm-border);margin-bottom:var(--dm-space-2)}.live-preview-widths{display:inline-flex;border:1px solid var(--dm-border);border-radius:var(--dm-radius-sm);overflow:hidden}.live-preview-width{display:inline-flex;align-items:center;justify-content:center;padding:.3rem .55rem;border:none;background:transparent;color:inherit;cursor:pointer;line-height:1}.live-preview-width+.live-preview-width{border-left:1px solid var(--dm-border)}.live-preview-width:hover{background:var(--dm-hover-bg)}.live-preview-width.active{background:var(--dm-active-bg)}.live-preview-status{flex:1 1 auto;font-size:.8rem;opacity:.75}.live-preview-actions{display:flex;gap:var(--dm-space-1)}.live-preview-stage{display:flex;justify-content:center;height:calc(100vh - 230px);overflow:auto;background:var(--dm-background-alt);border-radius:var(--dm-radius-md)}.page-live-preview-frame{display:block;width:100%;max-width:100%;height:100%;border:none;border-radius:6px;background:var(--dm-background);transition:max-width .18s ease}.page-live-preview-frame[data-width="1024"]{max-width:1024px}.page-live-preview-frame[data-width="390"]{max-width:390px}.page-live-preview-frame[data-width="1024"],.page-live-preview-frame[data-width="390"]{border-left:1px solid var(--dm-border);border-right:1px solid var(--dm-border)}.editor-card{display:flex;flex-direction:column;height:100%}.editor-card .card-body{display:flex;flex-direction:column;flex:1;min-height:0;padding:0!important}.editor-toolbar{display:flex;align-items:center;gap:2px;padding:6px 10px;border-bottom:1px solid var(--dm-border, rgba(255, 255, 255, .08));flex-wrap:wrap;flex-shrink:0}.editor-toolbar-btn{background:none;border:none;color:var(--dm-text-muted, #888);padding:6px 8px;border-radius:4px;cursor:pointer;display:flex;align-items:center;transition:color .15s,background .15s}.editor-toolbar-btn:hover{color:var(--dm-text, #eee);background:var(--dm-hover-bg)}.editor-toolbar-sep{width:1px;height:20px;background:var(--dm-border, rgba(255, 255, 255, .08));margin:0 4px;flex-shrink:0}.editor-toolbar-right{margin-left:auto;display:flex;align-items:center;gap:2px}.editor-view-btn{background:none;border:none;color:var(--dm-text-muted, #888);padding:6px 8px;border-radius:4px;cursor:pointer;display:flex;align-items:center;transition:color .15s,background .15s}.editor-view-btn:hover{color:var(--dm-text, #eee);background:var(--dm-hover-bg)}.editor-view-btn.active{color:var(--dm-primary, #5b8cff);background:color-mix(in srgb,var(--dm-primary) 14%,transparent)}.editor-body{display:flex;flex:1;min-height:0;--dm-editor-split: 50%}@media(min-width:769px){.editor-body:not(.editor-mode-write):not(.editor-mode-preview) .editor-pane--write{flex:0 0 var(--dm-editor-split, 50%)}.editor-body:not(.editor-mode-write):not(.editor-mode-preview) .editor-pane--preview{flex:1 1 0}}.editor-pane{flex:1;display:flex;flex-direction:column;min-width:0}.editor-pane--write,.editor-pane--preview{overflow:hidden}.editor-pane--write{flex-direction:row}.editor-line-numbers{padding:1rem .6rem 1rem .75rem;background:var(--dm-background-alt, rgba(0, 0, 0, .15));border-right:1px solid var(--dm-border, rgba(255, 255, 255, .08));color:var(--dm-text-muted, #666);font-family:Fira Code,Courier New,monospace;font-size:.9rem;line-height:1.6;text-align:right;user-select:none;white-space:pre;overflow:hidden;flex-shrink:0;min-width:2.5rem}.editor-line-numbers--foldable{padding:1rem 0;min-width:3rem;white-space:normal;text-align:left;display:flex;flex-direction:column}.editor-line-number-row{display:flex;align-items:center;justify-content:flex-end;padding-right:.6rem;gap:.15rem}.fold-toggle{display:inline-flex;align-items:center;justify-content:center;width:1em;font-size:.6rem;cursor:default;color:transparent}.fold-toggle--open,.fold-toggle--folded{cursor:pointer;color:var(--dm-text-muted, #666);transition:color .1s}.fold-toggle--open:hover,.fold-toggle--folded:hover{color:var(--dm-primary, #4a9eff)}.editor-line-num{min-width:1.5rem;text-align:right}.editor-mode-write .editor-pane--preview,.editor-mode-write .editor-divider,.editor-mode-preview .editor-pane--write,.editor-mode-preview .editor-divider{display:none}.editor-divider{width:7px;background:linear-gradient(to right,transparent 0,transparent 3px,var(--dm-border, rgba(255,255,255,.08)) 3px,var(--dm-border, rgba(255,255,255,.08)) 4px,transparent 4px,transparent 100%);cursor:col-resize;flex-shrink:0;touch-action:none;transition:background .12s ease}.editor-divider:hover,.editor-divider:focus-visible,.editor-divider.is-dragging{background:linear-gradient(to right,transparent 0,transparent 2px,var(--dm-primary, #5b8cff) 2px,var(--dm-primary, #5b8cff) 5px,transparent 5px,transparent 100%);outline:none}.editor-body.is-resizing .editor-pane{pointer-events:none}.editor-body.is-resizing{cursor:col-resize;user-select:none}.editor-textarea{flex:1;resize:none;border:none;border-radius:0;background:transparent;font-family:Fira Code,Courier New,monospace;font-size:.9rem;padding:1rem;outline:none;color:inherit;line-height:1.6}.editor-preview{flex:1;display:block;width:100%;border:none;background:var(--dm-background)}.editor-fullscreen{position:fixed;inset:0;z-index:1000;border-radius:0;margin:0}.editor-fullscreen .card-body{height:100vh}.media-picker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:.75rem;max-height:400px;overflow-y:auto;padding:.25rem}.media-picker-item{cursor:pointer;border:2px solid transparent;border-radius:6px;overflow:hidden;transition:border-color .15s}.media-picker-item:hover{border-color:var(--dm-primary, #5b8cff)}.media-picker-item img{width:100%;height:80px;object-fit:cover;display:block}.media-picker-item span{display:block;font-size:.75rem;padding:4px 6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.docs-body h3{margin-top:1.25rem;margin-bottom:.5rem}.docs-body p{margin-bottom:.5rem;line-height:1.6}.docs-body ol,.docs-body ul{margin-bottom:.75rem;padding-left:1.25rem}.docs-body li{margin-bottom:.25rem}.docs-body hr{margin:1.25rem 0;border-color:var(--dm-border, rgba(255, 255, 255, .08))}.docs-body .table{margin-bottom:.75rem}.nav-items-header,.nav-item-row{display:flex;gap:.5rem;align-items:center;padding:.35rem .75rem}.nav-items-header{border-bottom:1px solid var(--dm-border, rgba(255,255,255,.08));padding-bottom:.4rem;margin-bottom:.25rem}.nav-item-row{border-bottom:1px solid var(--dm-border, rgba(255,255,255,.04))}.nav-item-row:last-child{border-bottom:none}.nav-col-indent{width:18px;flex-shrink:0;color:var(--dm-text-muted, #888);text-align:center;font-size:.85rem}.nav-col-main{flex:2;min-width:0}.nav-col-icon{flex:0 0 90px}.nav-col-parent{flex:2;min-width:0}.nav-col-action{flex-shrink:0}.nav-item-row--child{background:color-mix(in srgb,var(--dm-text) 3%,transparent)}.nav-col-action{display:flex;align-items:center;gap:.25rem}.nav-item-row--hidden{opacity:.45}.nav-item-row--hidden .item-text,.nav-item-row--hidden .footer-link-text{text-decoration:line-through}.btn-toggle-hidden{color:var(--dm-text-muted, #888)}.btn-toggle-hidden.active{color:var(--dm-warning, #f59e0b)}.fb-field-card.fb-drag-over{outline:2px dashed var(--dm-primary, #6366f1);outline-offset:-2px}.btn-ghost span[data-icon=trash]{color:var(--dm-danger, #dc2626)}.view-container:has(.fb-fields-layout){max-width:none}.fb-fields-layout{display:grid;grid-template-columns:minmax(0,6fr) minmax(320px,4fr);gap:1.25rem;align-items:start}.fb-preview-pane{position:sticky;top:1rem;min-width:0}@media(max-width:1100px){.fb-fields-layout{grid-template-columns:1fr}.fb-preview-pane{position:static}}.toggle-label{display:flex;align-items:center;gap:.4rem;font-size:.9rem;cursor:pointer;margin-right:1rem}.preset-toggles{display:flex;flex-wrap:wrap;margin-top:.75rem}.presets-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}@media(max-width:900px){.presets-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:600px){.presets-grid{grid-template-columns:1fr}}.card-header{padding:.5rem 1rem}.card-header h2{margin:0;font-size:.9rem;font-weight:600;letter-spacing:.02em}.preset-card .card-header{display:flex;align-items:center;justify-content:space-between}.preset-card .card-header h3{margin:0;font-size:1rem}.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem}.media-card{background:var(--dm-card-bg, rgba(255,255,255,.04));border:1px solid var(--dm-border, rgba(255,255,255,.08));border-radius:8px;overflow:hidden}.media-preview{height:130px;display:flex;align-items:center;justify-content:center;background:#0003;overflow:hidden}.media-thumb{width:100%;height:100%;object-fit:cover}.media-thumb--file{font-size:2rem;color:var(--dm-text-muted, #888)}.media-info{padding:.5rem .75rem;display:flex;flex-direction:column;gap:.15rem}.media-name{font-size:.8rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:default}.media-rename-input{width:100%;font-size:.8rem;font-weight:500;padding:.1rem .25rem;border:1px solid var(--dm-primary, #7c6af7);border-radius:3px;background:var(--dm-input-bg, transparent);color:inherit;outline:none}.media-size{font-size:.75rem;color:var(--dm-text-muted, #888)}.media-actions{padding:.5rem .75rem;display:flex;gap:.4rem;border-top:1px solid var(--dm-border, rgba(255,255,255,.08))}#admin-topbar{height:60px;display:flex;align-items:center;gap:1rem;padding:0 1.25rem;background:var(--dm-navbar-bg, rgba(0,0,0,.3));border-bottom:1px solid var(--dm-border, rgba(255,255,255,.08));position:sticky;top:0;z-index:200;flex-shrink:0}.topbar-brand{display:flex;align-items:center;gap:.5rem;font-weight:700;font-size:.95rem;color:inherit;flex-shrink:0;margin-right:.5rem}.topbar-brand span[data-icon],.topbar-brand svg{color:var(--dm-primary, #5b8cff);width:22px;height:22px}.topbar-brand-text{opacity:.85}.topbar-brand-logo{height:28px;width:auto;display:inline-block;vertical-align:middle;margin-right:.4em;object-fit:contain}.topbar-user{display:flex;align-items:center;gap:.6rem;flex:1}.topbar-user-name{font-size:.875rem;font-weight:500}.topbar-avatar{width:1.75rem;height:1.75rem;border-radius:50%;object-fit:cover;flex-shrink:0}.topbar-role-badge{font-size:.7rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;padding:.15rem .45rem;border-radius:4px}.topbar-role-badge--admin{background:color-mix(in srgb,var(--dm-primary) 15%,transparent);color:var(--dm-primary)}.topbar-role-badge--manager{background:color-mix(in srgb,var(--dm-success) 15%,transparent);color:var(--dm-success)}.topbar-role-badge--editor{background:color-mix(in srgb,var(--dm-warning) 15%,transparent);color:var(--dm-warning)}.topbar-role-badge--subscriber{background:color-mix(in srgb,var(--dm-text-muted) 15%,transparent);color:var(--dm-text-muted)}.topbar-actions{display:flex;align-items:center;gap:.5rem;flex-shrink:0}.topbar-action-link{display:flex;align-items:center;gap:.35rem;font-size:.875rem;color:var(--dm-text-muted, #888);text-decoration:none;padding:.4rem .65rem;border-radius:6px;transition:color .15s,background .15s}.topbar-action-link:hover{color:var(--dm-text, #eee);background:var(--dm-hover-bg)}.topbar-action-link span[data-icon],.topbar-action-link svg{width:16px;height:16px}.topbar-signout{display:flex;align-items:center;gap:.35rem;font-size:.875rem}.topbar-signout span[data-icon],.topbar-signout svg{width:16px;height:16px}.login-wrap{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:1rem;background:var(--dm-background, #0f1117);z-index:100;overflow-y:auto}.login-card{width:100%;max-width:460px}.ob-done-icon{font-size:3rem;color:var(--dm-success);margin-bottom:1rem;text-align:center}.btn-skip{display:block;text-align:center;margin-top:.75rem;font-size:.85rem;color:var(--dm-text-muted, #888);text-decoration:none}.btn-skip:hover{text-decoration:underline}.theme-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:.6rem}.theme-swatch{border:2px solid var(--dm-border, rgba(255,255,255,.1));border-radius:8px;overflow:hidden;cursor:pointer;transition:border-color .15s,transform .1s}.theme-swatch:hover{border-color:var(--dm-primary, #5b8cff);transform:translateY(-1px)}.theme-swatch.selected{border-color:var(--dm-primary, #5b8cff);box-shadow:0 0 0 2px var(--dm-primary, #5b8cff)}.theme-swatch-preview{height:52px;position:relative;display:flex;align-items:flex-end;padding:.35rem}.theme-swatch-accent{width:28px;height:6px;border-radius:3px}.theme-swatch-label{display:flex;justify-content:space-between;align-items:center;padding:.3rem .45rem;font-size:.7rem;background:var(--dm-card-bg, rgba(255,255,255,.04))}.theme-swatch-mode{color:var(--dm-text-muted, #888);font-size:.65rem}.login-logo{display:flex;align-items:center;gap:.75rem;margin-bottom:1.75rem;font-size:1.5rem}.login-logo span[data-icon]{font-size:2rem;color:var(--dm-primary, #5b8cff)}.login-logo h1{margin:0;font-size:1.5rem;font-weight:700}.login-card [hidden]{display:none!important}.login-heading{font-size:1.1rem;font-weight:600;margin-bottom:1.25rem}.login-rules{margin:.25rem 0 .75rem;padding-left:1.1rem;color:var(--dm-danger);font-size:.85rem}.login-rules li+li{margin-top:.2rem}.login-small{font-size:.8rem;margin-top:.75rem}.login-icon-warn{color:var(--dm-warning)}.pwf{position:relative;display:block}.pwf>input{width:100%;padding-right:2.5rem}.pwf-toggle{position:absolute;top:50%;right:.35rem;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;padding:0;border:0;border-radius:var(--dm-radius-sm);background:transparent;color:var(--dm-text-muted);cursor:pointer}.pwf-toggle:hover,.pwf-toggle:focus-visible{color:var(--dm-text);background:var(--dm-hover-bg)}.pwf-toggle svg{width:16px;height:16px}.pwf-toggle [data-icon=eye-off],.pwf.is-shown .pwf-toggle [data-icon=eye]{display:none}.pwf.is-shown .pwf-toggle [data-icon=eye-off]{display:inline-block}.pwf-caps{display:block;margin-top:.25rem;color:var(--dm-warning);font-size:.8rem}.pwf-caps[hidden]{display:none}.btn-block{width:100%}.plugins-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}.plugin-card{display:flex;flex-direction:column;--plugin-accent: var(--dm-primary, #5b8cff);position:relative;overflow:hidden;background-image:radial-gradient(120% 90% at 100% 0%,color-mix(in srgb,var(--plugin-accent) 13%,transparent) 0%,color-mix(in srgb,var(--plugin-accent) 4%,transparent) 42%,transparent 72%)}.plugin-card .plugin-icon{background:color-mix(in srgb,var(--plugin-accent) 16%,transparent);color:var(--plugin-accent)}.plugin-watermark{position:absolute;top:-34px;right:-30px;width:148px;height:148px;display:block;color:var(--plugin-accent);opacity:.09;pointer-events:none;z-index:0}.plugin-card>.card-body,.plugin-card>.plugin-footer{position:relative;z-index:1}.plugin-card:has(.badge-secondary) .plugin-watermark{opacity:.05}@media(prefers-reduced-motion:no-preference){.plugin-card{transition:background-image .2s ease}}.plugin-card .card-body{flex:1}.plugin-header{display:flex;align-items:flex-start;gap:.75rem;margin-bottom:.75rem;flex-wrap:wrap}.plugin-icon{width:40px;height:40px;border-radius:8px;background:color-mix(in srgb,var(--dm-primary) 14%,transparent);color:var(--dm-primary, #5b8cff);display:flex;align-items:center;justify-content:center;flex-shrink:0}.plugin-meta{flex:1 1 140px;min-width:0}.plugin-name{font-weight:600;margin-bottom:.15rem}.plugin-version{font-size:.75rem;color:var(--dm-text-muted, #888)}.plugin-desc{font-size:.875rem;color:var(--dm-text-muted, #888);margin-bottom:1rem}.plugin-footer{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;border-top:1px solid var(--dm-border, rgba(255,255,255,.08))}.plugin-footer-actions{display:flex;align-items:center;gap:.5rem}#admin-sidebar .sidebar-link{position:relative!important}#admin-sidebar .sidebar-badge{position:absolute!important;right:.75rem!important;top:50%!important;transform:translateY(-50%)!important;margin-left:0!important;padding-left:.45rem!important;padding-right:.45rem!important}#admin-sidebar .sidebar-text{padding-right:2rem!important}#admin-sidebar.sidebar-dark{background:var(--dm-surface);border-color:var(--dm-border)}#admin-sidebar.sidebar-dark .sidebar-header{background:var(--dm-surface-raised);border-color:var(--dm-border)}#admin-sidebar.sidebar-dark .sidebar-header-title{color:var(--dm-text)}#admin-sidebar.sidebar-dark .sidebar-link{color:var(--dm-text-muted)}#admin-sidebar.sidebar-dark .sidebar-link:hover{color:var(--dm-text);background:var(--dm-surface-overlay)}#admin-sidebar.sidebar-dark .sidebar-link.active{color:var(--dm-primary);background:color-mix(in srgb,var(--dm-primary) 14%,transparent);border-left-color:var(--dm-primary)}#admin-sidebar.sidebar-dark .sidebar-heading{color:var(--dm-text-muted)}#admin-sidebar.sidebar-dark .sidebar-divider{background:var(--dm-border)}#admin-sidebar.sidebar-dark .sidebar-footer{background:var(--dm-surface-raised);border-color:var(--dm-border)}.sidebar-heading--collapsible{display:flex!important;align-items:center;justify-content:space-between;cursor:pointer;user-select:none}.sidebar-heading-toggle{display:flex;align-items:center;opacity:.5;flex-shrink:0;transition:transform .2s ease}.sidebar-heading--collapsible.is-collapsed .sidebar-heading-toggle{transform:rotate(-90deg)}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.p-0{padding:0!important}.text-muted{color:var(--dm-text-muted, #888)}@media(max-width:768px){.view-container{padding:1rem}.editor-body{flex-direction:column}.editor-divider{width:auto;height:1px;cursor:default;background:var(--dm-border, rgba(255,255,255,.08))}}.image-editor{display:flex;flex-direction:column;gap:0}.image-editor-toolbar{display:flex;flex-direction:row;align-items:center;gap:.25rem;padding:.5rem .75rem;border-bottom:1px solid var(--dm-border, rgba(255, 255, 255, .1))}.image-editor-sep{display:inline-block;width:1px;height:1.5rem;background:var(--dm-border, rgba(255, 255, 255, .15));margin:0 .25rem;flex-shrink:0}.editor-toolbar-btn.active{color:var(--dm-primary, #7c6af7);background:color-mix(in srgb,var(--dm-primary, #7c6af7) 15%,transparent)}.editor-toolbar-caret{font-size:.6rem;line-height:1;margin-left:2px;opacity:.6}.editor-toolbar-dropdown{position:absolute;z-index:1000;background:var(--dm-surface, #1e1e2e);border:1px solid var(--dm-border, rgba(255, 255, 255, .1));border-radius:8px;box-shadow:0 8px 24px #0006;padding:4px;min-width:160px;max-height:70vh;overflow-y:auto}.editor-toolbar-dropdown-heading{padding:6px 10px 3px;color:var(--dm-text-muted, #8a8a9a);font-size:.66rem;text-transform:uppercase;letter-spacing:.06em}.editor-toolbar-dropdown-item{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:none;border:none;color:var(--dm-text, #ddd);padding:7px 10px;border-radius:5px;cursor:pointer;font-size:.85rem;transition:background .12s,color .12s}.editor-toolbar-dropdown-item:hover{background:var(--dm-hover-bg);color:var(--dm-text, #fff)}.editor-toolbar-dropdown-item span[data-icon],.editor-toolbar-dropdown-item svg{width:15px;height:15px;flex-shrink:0;opacity:.75}.editor-toolbar{position:relative}.editor-effects-dropdown-menu{position:absolute;top:100%;z-index:1000;background:var(--dm-surface, #1e1e2e);border:1px solid var(--dm-border, rgba(255, 255, 255, .1));border-radius:8px;box-shadow:0 8px 24px #0006;padding:6px;min-width:180px;max-height:340px;overflow-y:auto}.editor-effects-category{padding:6px 8px 3px;font-size:.7rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--dm-text-muted, #888)}.editor-effects-item{display:block;width:100%;text-align:left;background:none;border:none;color:var(--dm-text, #ddd);padding:6px 10px;border-radius:5px;cursor:pointer;font-size:.85rem;transition:background .12s,color .12s}.editor-effects-item:hover{background:var(--dm-hover-bg);color:var(--dm-text, #fff)}.editor-icon-picker{position:absolute;z-index:1000;background:var(--dm-surface, #1e1e2e);border:1px solid var(--dm-border, rgba(255, 255, 255, .1));border-radius:8px;box-shadow:0 8px 24px #0006;padding:8px;width:320px}.editor-icon-picker-search{width:100%;margin-bottom:8px;font-size:.8rem}.editor-spacer-picker{position:absolute;z-index:9999;background:var(--dm-surface, #1e1e2e);border:1px solid var(--dm-border, #333);border-radius:8px;padding:12px;width:240px;box-shadow:0 8px 24px #00000073}.editor-spacer-picker-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--dm-text-muted, #888);margin-bottom:10px}.editor-spacer-picker-row{display:flex;align-items:center;gap:10px;margin-bottom:12px}.editor-spacer-slider{flex:1;accent-color:var(--dm-primary, #6366f1);cursor:pointer}.editor-spacer-slider-value{font-size:13px;font-weight:600;min-width:38px;text-align:right;color:var(--dm-text, #cdd6f4)}.editor-spacer-insert-btn{width:100%}.editor-icon-picker-size-row{display:flex;align-items:center;gap:8px;padding:0 8px 8px}.editor-icon-picker-size-row label{font-size:11px;color:var(--dm-text-muted, #888);white-space:nowrap}.editor-icon-picker-size{width:70px;font-size:12px;padding:3px 6px}.editor-icon-picker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(52px,1fr));gap:2px;max-height:280px;overflow-y:auto}.editor-icon-picker-item{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:6px 2px;border-radius:5px;cursor:pointer;background:none;border:none;color:var(--dm-text, #ddd);font-size:.58rem;text-align:center;gap:4px;overflow:hidden;transition:background .12s,color .12s}.editor-icon-picker-item:hover{background:var(--dm-hover-bg);color:var(--dm-text, #fff)}.editor-icon-picker-item span[data-icon],.editor-icon-picker-item svg{width:16px;height:16px;flex-shrink:0}.editor-icon-picker-item>span:last-child{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.2}.editor-icon-picker-empty{grid-column:1 / -1;text-align:center;color:var(--dm-text-muted, #888);font-size:.8rem;padding:1.5rem}.image-editor-canvas{min-height:320px;max-height:520px;background:#111;overflow:hidden;display:flex;align-items:center;justify-content:center}.image-editor-canvas img{display:block;max-width:100%;max-height:520px}.image-editor-resize{display:flex;flex-direction:row;align-items:center;gap:.5rem;padding:.6rem .75rem;border-top:1px solid var(--dm-border, rgba(255, 255, 255, .1));font-size:.85rem}.image-editor-resize label{font-weight:600;color:var(--dm-text-muted, #888);font-size:.8rem}.image-editor-resize .form-input{width:5rem;padding:.25rem .5rem;font-size:.85rem}.image-editor-footer{display:flex;flex-direction:row;align-items:center;justify-content:space-between;padding:.75rem;border-top:1px solid var(--dm-border, rgba(255, 255, 255, .1));gap:.5rem}.image-editor-effects{border-top:1px solid var(--dm-border, rgba(255, 255, 255, .1))}.image-editor-tab-bar{display:flex;flex-direction:row;overflow-x:auto;border-bottom:1px solid var(--dm-border, rgba(255, 255, 255, .1));scrollbar-width:none}.image-editor-tab-bar::-webkit-scrollbar{display:none}.image-editor-tab-btn{display:inline-flex;align-items:center;gap:.35rem;padding:.5rem .85rem;font-size:.8rem;font-weight:500;border:none;border-bottom:2px solid transparent;background:transparent;cursor:pointer;color:var(--dm-text-muted, #888);white-space:nowrap;transition:color .15s,border-color .15s;flex-shrink:0}.image-editor-tab-btn:hover{color:var(--dm-text, #eee)}.image-editor-tab-btn.active{color:var(--dm-primary, #7c6af7);border-bottom-color:var(--dm-primary, #7c6af7)}.image-editor-tab-panel{padding:.75rem;max-height:200px;overflow-y:auto}.ie-presets-grid{display:flex;flex-wrap:wrap;gap:.35rem;margin-bottom:.5rem}.ie-preset-btn.active{color:var(--dm-primary, #7c6af7);background:color-mix(in srgb,var(--dm-primary, #7c6af7) 15%,transparent);border-color:var(--dm-primary, #7c6af7)}.ie-server-note{font-size:.73rem;color:var(--dm-text-muted, #888);font-style:italic;margin:.25rem 0 0;line-height:1.4}.ie-slider-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem}.ie-slider-label{width:6rem;font-size:.8rem;color:var(--dm-text-muted, #888);font-weight:600;flex-shrink:0}.ie-slider{flex:1;accent-color:var(--dm-primary, #7c6af7);cursor:pointer}.ie-slider-val{width:3rem;font-size:.8rem;text-align:right;color:var(--dm-text, #eee);flex-shrink:0}.ie-select{flex:1;font-size:.85rem;padding:.25rem .5rem}.ie-colour-input{width:40px;height:28px;padding:0;border:1px solid var(--dm-border, rgba(255, 255, 255, .15));border-radius:4px;cursor:pointer;background:transparent;flex-shrink:0}.ie-wm-preview-row{display:flex;align-items:center;gap:.5rem;margin:.4rem 0}.ie-wm-preview-img{width:48px;height:48px;object-fit:contain;border-radius:4px;background:var(--dm-surface-raised, rgba(255, 255, 255, .06));flex-shrink:0}.ie-wm-preview-name{font-size:.8rem;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dm-text, #eee)}.ie-media-picker{padding:.25rem 0}.ie-media-picker-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:.5rem;max-height:300px;overflow-y:auto}.ie-media-thumb{display:flex;flex-direction:column;align-items:center;gap:.25rem;padding:.4rem;border-radius:6px;cursor:pointer;border:2px solid transparent;transition:border-color .15s,background .15s}.ie-media-thumb:hover{background:var(--dm-surface-raised, rgba(255, 255, 255, .08));border-color:var(--dm-primary, #7c6af7)}.ie-media-thumb img{width:72px;height:72px;object-fit:cover;border-radius:4px}.ie-media-thumb-name{font-size:.7rem;color:var(--dm-text-muted, #888);text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:72px}.dm-slideover-left,.dm-slideover-right{width:min(40rem,95vw)!important;min-width:min(36rem,100vw)!important;max-width:100vw!important}.dm-slideover-resizer{position:absolute;top:0;bottom:0;width:6px;cursor:col-resize;z-index:2;background:transparent}.dm-slideover-right>.dm-slideover-resizer{left:0}.dm-slideover-left>.dm-slideover-resizer{right:0}.dm-slideover-resizer:hover,.is-resizing>.dm-slideover-resizer{background:var(--dm-primary);opacity:.7}.dm-slideover.is-resizing{user-select:none;transition:none!important}.dm-slideover-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid var(--dm-border, #333);flex-shrink:0}.dm-slideover-title{margin:0;font-size:1rem;font-weight:600}.dm-slideover-close{padding:.2rem .35rem!important;line-height:1;font-size:.75rem}.dm-slideover-body{flex:1;overflow:auto;overscroll-behavior:contain;min-height:0;padding:1.5rem!important;box-sizing:border-box}.dm-slideover-body:empty{flex:0 0 auto;padding:0!important}.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-body):not(.dm-slideover-close){padding:1.5rem!important;box-sizing:border-box;flex:1 1 auto;min-height:0;overflow:auto;overscroll-behavior:contain}.dm-slideover-body,.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-close){scrollbar-width:thin;scrollbar-color:var(--dm-border) transparent}.dm-slideover-body::-webkit-scrollbar,.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-close)::-webkit-scrollbar{width:8px;height:8px}.dm-slideover-body::-webkit-scrollbar-track,.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-close)::-webkit-scrollbar-track{background:transparent}.dm-slideover-body::-webkit-scrollbar-thumb,.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-close)::-webkit-scrollbar-thumb{background:var(--dm-border);border-radius:4px}.dm-slideover-body::-webkit-scrollbar-thumb:hover,.dm-slideover>div:not(.dm-slideover-header):not(.dm-slideover-close)::-webkit-scrollbar-thumb:hover{background:var(--dm-text-muted)}.dm-slideover-body>div{padding-left:0!important;padding-right:0!important}.dm-slideover-body>button.btn:last-child{margin-top:.5rem}.dconfig-textarea{font-family:monospace;min-height:100px;resize:vertical}.dm-editor-line-numbers{white-space:pre}.dm-code-inline{font-family:Fira Code,Courier New,monospace;font-size:.82em;padding:.15em .35em;border-radius:3px;background:var(--dm-background-alt, rgba(0, 0, 0, .18));color:var(--dm-text, inherit);border:1px solid var(--dm-border, rgba(255, 255, 255, .08))}.dm-code-block{font-family:Fira Code,Courier New,monospace;font-size:.82rem;line-height:1.6;padding:.65rem .9rem;border-radius:6px;background:var(--dm-background-alt, rgba(0, 0, 0, .18));color:var(--dm-text, inherit);border:1px solid var(--dm-border, rgba(255, 255, 255, .08));white-space:pre;overflow-x:auto;display:block;margin:.4rem 0}.tabs-centered{text-align:center}.tabs-centered .tab-list{display:inline-flex}.tabs-centered .tab-content{text-align:left}@media(max-width:768px){.view-header{flex-direction:column;align-items:flex-start}.theme-grid{grid-template-columns:repeat(2,1fr)}.nav-item-row{flex-wrap:wrap}.nav-col-icon,.nav-col-parent{display:none}#admin-topbar{padding:0 .75rem}.topbar-brand-text{display:none}.image-editor-toolbar{flex-wrap:wrap}}#topbar-bell{position:relative}.topbar-action-link[hidden],.topbar-bell-badge[hidden]{display:none}.topbar-bell-badge{position:absolute;top:2px;right:2px;min-width:16px;height:16px;padding:0 4px;background:var(--dm-danger, #dc2626);color:var(--dm-danger-text, #fff);font-size:10px;font-weight:700;line-height:16px;border-radius:9999px;text-align:center;pointer-events:none}.cb-scaffolder{padding:1rem 1.1rem;background:var(--dm-background-alt);border:1px solid var(--dm-primary);border-radius:8px;color:var(--dm-text)}.cb-scaffolder-head{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}.cb-scaffolder-head .cb-icon{flex-shrink:0;color:var(--dm-primary)}.cb-scaffolder-head h2{margin:0;font-size:1.05rem;font-weight:700;color:var(--dm-text)}.cb-scaffolder-blurb{margin:0 0 .75rem;font-size:.88rem;line-height:1.5;color:var(--dm-text-muted)}.cb-recipe-list{display:flex;flex-direction:column;gap:.5rem}.cb-recipe-card{display:flex;align-items:flex-start;gap:.75rem;text-align:left;padding:.75rem 1rem;background:var(--dm-surface);border:1px solid var(--dm-border);border-radius:6px;cursor:pointer;transition:border-color .12s;width:100%;color:var(--dm-text)}.cb-recipe-card:hover{border-color:var(--dm-primary)}.cb-recipe-card .cb-icon{flex-shrink:0;font-size:1.5rem;color:var(--dm-primary);margin-top:.1rem}.cb-recipe-meta{flex:1}.cb-recipe-name{font-weight:600;margin-bottom:.15rem;font-size:.95rem;color:var(--dm-text)}.cb-recipe-desc{font-size:.83rem;line-height:1.45;color:var(--dm-text-muted)}.cb-apply-panel{padding:1rem 1.1rem;background:var(--dm-surface);border:1px solid var(--dm-primary);border-radius:8px;color:var(--dm-text)}.cb-apply-head{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}.cb-apply-head .cb-icon{color:var(--dm-primary);font-size:1.4rem}.cb-apply-head h3{margin:0;font-size:1rem;font-weight:600;color:var(--dm-text)}.cb-apply-desc,.cb-apply-hint{margin:.25rem 0 .75rem;font-size:.85rem;line-height:1.5;color:var(--dm-text-muted)}.cb-apply-hint{font-size:.75rem;margin-top:.2rem}.cb-apply-field{margin-bottom:.6rem}.cb-apply-field label{display:block;font-size:.82rem;font-weight:600;margin-bottom:.2rem;color:var(--dm-text)}.cb-apply-field input{width:100%;padding:.4rem .55rem;border:1px solid var(--dm-border);border-radius:4px;font-size:.88rem;background:var(--dm-surface);color:var(--dm-text)}.cb-apply-status{margin:.5rem 0;font-size:.85rem;min-height:1.2rem;color:var(--dm-text)}.cb-apply-status.cb-error{color:var(--dm-danger, #dc2626)}.cb-apply-buttons{display:flex;gap:.5rem;justify-content:flex-end;margin-top:.5rem}.cb-success-panel{padding:1rem 1.1rem;background:var(--dm-surface);border:1px solid var(--dm-success, #16a34a);border-radius:8px;color:var(--dm-text)}.cb-success-panel .cb-icon{color:var(--dm-success, #16a34a);font-size:1.4rem}.cb-success-summary{margin:.25rem 0 .5rem;padding-left:1.2rem;font-size:.86rem;line-height:1.6;color:var(--dm-text)}.cb-success-warning{margin:.5rem 0;padding:.5rem .75rem;background:var(--dm-background-alt);border-left:3px solid var(--dm-warning, #eab308);border-radius:0 4px 4px 0;font-size:.82rem;color:var(--dm-text-muted)}.cb-success-snippet-label{margin:0 0 .25rem;font-size:.82rem;font-weight:600;color:var(--dm-text)}.cb-success-actions{display:flex;gap:.5rem;margin-top:.5rem;flex-wrap:wrap}.cb-code{position:relative}.cb-code pre{background:var(--dm-background-alt);color:var(--dm-text);display:block;padding:.6rem 2.4rem .6rem .75rem;white-space:pre;overflow-x:auto;margin:0;border-radius:6px;font-size:.82em;font-family:monospace;line-height:1.45}.cb-code .cb-copy{position:absolute;top:.35rem;right:.35rem;background:none;border:none;cursor:pointer;opacity:.45;padding:.2rem;line-height:1;color:var(--dm-text)}.cb-code .cb-copy:hover{opacity:1}.cb-intro{padding:.75rem 1rem;border-left:3px solid var(--dm-primary);background:var(--dm-background-alt);border-radius:0 6px 6px 0}.cb-intro p{margin:0;font-size:.92rem;line-height:1.5;color:var(--dm-text)}.cb-tutorial-link{display:flex;align-items:center;gap:.6rem;padding:.65rem .85rem;border:1px solid var(--dm-border);border-radius:6px;text-decoration:none;color:var(--dm-text);font-size:.88rem;transition:border-color .12s}.cb-tutorial-link:hover{border-color:var(--dm-primary)}.cb-tutorial-link .cb-icon{flex-shrink:0;color:var(--dm-primary)}.cb-tutorial-link .cb-arrow{color:var(--dm-text-muted);font-size:1.1rem;margin-left:auto}.menu-tree-row{display:grid;grid-template-columns:16px 1.4fr 1.4fr .9fr .7fr .9fr repeat(6,auto);gap:6px;align-items:center;padding:4px 0;transition:opacity .15s ease}.menu-tree-row--hidden{opacity:.5}.menu-tree-row--tool .me-tool-badge{margin-left:.4rem;font-size:.7rem;vertical-align:middle}.menu-tree-row--default .me-row-label{font-style:italic;opacity:.8}.menu-tree-grip{cursor:grab;color:var(--dm-text-muted)}.menu-tree{padding:8px 0}.menu-tree-row--separator{display:grid;grid-template-columns:16px minmax(80px,1fr) repeat(7,auto);gap:6px;align-items:center;padding:2px 0;opacity:.85}.menu-tree-row--separator:hover{opacity:1}.menu-tree-row--separator .me-sep-line{display:flex;align-items:center;gap:10px;width:100%}.menu-tree-row--separator .me-sep-line:before,.menu-tree-row--separator .me-sep-line:after{content:"";flex:1;border-top:1px dashed var(--dm-border, rgba(128, 128, 128, .55))}.menu-tree-row--separator .me-sep-label{font-size:.66rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--dm-text-muted, #8a8a8a);white-space:nowrap}.dm-admin-sidebar .sidebar-divider{border:0;border-top:1px solid var(--dm-border, rgba(128, 128, 128, .5));margin:.5rem .75rem;opacity:.8}.dm-admin-sidebar .sidebar-spacer--xs{height:var(--dm-space-1, .25rem)}.dm-admin-sidebar .sidebar-spacer--sm{height:var(--dm-space-2, .5rem)}.dm-admin-sidebar .sidebar-spacer--md{height:var(--dm-space-4, 1rem)}.dm-admin-sidebar .sidebar-spacer--lg{height:var(--dm-space-6, 1.5rem)}.dm-admin-sidebar .sidebar-spacer--xl{height:var(--dm-space-10, 2.5rem)}.dm-admin-sidebar .sidebar-spacer--flex{flex:1 1 auto;min-height:var(--dm-space-4, 1rem)}.dm-admin-sidebar--has-flex-spacer{display:flex;flex-direction:column;min-height:100%}.menu-tree-row--spacer{display:grid;grid-template-columns:16px minmax(80px,1fr) repeat(7,auto);gap:6px;align-items:center;padding:2px 0;opacity:.85}.menu-tree-row--spacer:hover{opacity:1}.menu-tree-row--spacer .me-spacer-line{display:flex;align-items:center;gap:8px;width:100%}.menu-tree-row--spacer .me-spacer-line:before,.menu-tree-row--spacer .me-spacer-line:after{content:"";flex:1;border-top:1px dotted var(--dm-border, rgba(128, 128, 128, .55))}.menu-tree-row--spacer .me-spacer-label{font-size:.66rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--dm-text-muted, #8a8a8a);white-space:nowrap}.menu-tree-row--spacer .me-spacer-size{width:auto;min-width:5.5rem;height:26px;padding:0 .35rem;font-size:.75rem}.menu-tree-row .me-icon-wrap{display:flex;gap:2px}.menu-tree-row .me-icon-wrap .me-icon{flex:1;min-width:0}.menu-tree-row .me-icon-wrap .me-icon-pick{flex-shrink:0;padding:.2rem .45rem;font-size:.85em}.dm-admin-sidebar{display:flex;flex-direction:column;gap:2px;padding:8px 0;font-size:.875rem}.dm-admin-sidebar details{margin:0}.dm-admin-sidebar details>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:.5rem;padding:.4rem .75rem;border-radius:4px;user-select:none;font-size:.78rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--dm-text-muted, #888)}.dm-admin-sidebar details details>summary{font-size:.875rem;font-weight:500;text-transform:none;letter-spacing:0;color:inherit}.dm-admin-sidebar .dm-menu-badge{font-size:.7rem;font-weight:600}.dm-admin-sidebar details>summary::-webkit-details-marker{display:none}.dm-admin-sidebar details>summary:after{content:"\25b8";margin-left:auto;transition:transform .15s ease;color:var(--dm-text-muted, #999);font-size:.85em}.dm-admin-sidebar details[open]>summary:after{transform:rotate(90deg)}.dm-admin-sidebar details>div{padding-left:1rem;border-left:1px solid var(--dm-border-light, rgba(0, 0, 0, .08));margin-left:.95rem}.dm-admin-sidebar .sidebar-link{display:flex;align-items:center;gap:.5rem;padding:.35rem .75rem;text-decoration:none;color:var(--dm-text, inherit);border-radius:4px}.dm-admin-sidebar .sidebar-link:hover,.dm-admin-sidebar details>summary:hover{background:var(--dm-hover-bg, rgba(0, 0, 0, .05))}.dm-admin-sidebar .sidebar-link.active{background:var(--dm-active-bg, rgba(0, 0, 0, .08));font-weight:600}.dm-admin-sidebar .sidebar-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dm-admin-sidebar [data-icon]{flex-shrink:0}.dm-admin-sidebar-header{position:sticky;top:0;z-index:2;background:var(--dm-surface);display:flex;align-items:center;gap:.5rem;padding:.65rem .85rem;margin:0 0 .5rem;font-weight:600;font-size:.95rem;letter-spacing:.01em;color:var(--dm-text, inherit);border-bottom:1px solid var(--dm-border-light, rgba(0, 0, 0, .08))}.dm-admin-sidebar--dark .dm-admin-sidebar-header{background:#1a1d24}.dm-admin-sidebar--light .dm-admin-sidebar-header{background:#fff}.dm-admin-sidebar-header [data-icon]{color:var(--dm-text-muted, #999)}.dm-admin-sidebar .sidebar-badge{margin-left:auto;padding:0 .4rem;min-width:1.25rem;height:1.1rem;line-height:1.1rem;text-align:center;font-size:.7rem;font-weight:600;border-radius:999px;background:var(--dm-active-bg, rgba(0, 0, 0, .12));color:var(--dm-text-muted, #888)}.dm-admin-sidebar .sidebar-link.active .sidebar-badge{background:var(--dm-primary, #4a86e8);color:var(--dm-primary-text, #fff)}.dm-admin-sidebar .sidebar-children{overflow:hidden;transition:max-height .22s ease,opacity .18s ease}.dm-admin-sidebar details:not([open])>.sidebar-children{opacity:0}.dm-admin-sidebar{position:relative}.dm-admin-sidebar-handle{position:absolute;top:0;right:0;width:6px;height:100%;cursor:col-resize;background:transparent;z-index:10;transition:background .15s}.dm-admin-sidebar-handle:hover,.dm-admin-sidebar-handle:active{background:var(--dm-primary, #4a86e8);opacity:.35}.dm-admin-sidebar--dark{background:#1a1d24;color:#e3e6eb}.dm-admin-sidebar--dark .sidebar-link,.dm-admin-sidebar--dark summary{color:#e3e6eb}.dm-admin-sidebar--dark .sidebar-link:hover,.dm-admin-sidebar--dark details>summary:hover{background:#ffffff0f}.dm-admin-sidebar--dark .sidebar-link.active{background:#4a86e840}.dm-admin-sidebar--dark .sidebar-badge{background:#ffffff1f;color:#cbd0d8}.dm-admin-sidebar--light{background:#fff;color:#1a1d24;border-right:1px solid rgba(0,0,0,.08)}.dm-admin-sidebar--light .sidebar-link,.dm-admin-sidebar--light summary{color:#1a1d24}.dm-admin-sidebar--light .sidebar-link:hover,.dm-admin-sidebar--light details>summary:hover{background:#0000000a}.dm-admin-sidebar--light .sidebar-link.active{background:#4a86e826}.dm-admin-sidebar--transparent{background:transparent}.page-header{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin:0 0 1.25rem;padding-bottom:.75rem;border-bottom:1px solid var(--dm-border-light, rgba(0, 0, 0, .08))}.page-header h2{margin:0;display:flex;align-items:center;gap:.5rem;font-size:1.4rem;line-height:1.2}.page-header h2 [data-icon]{color:var(--dm-text-muted, #888)}.header-actions{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.card+.card,.card+p,p+.card,.card+.toolbar,.toolbar+.card,.card+.grid{margin-top:1rem}.toolbar{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:0 0 1rem}.btn+.btn,.btn+a.btn,a.btn+.btn,a.btn+a.btn{margin-left:.35rem}[style*=inline-flex] .btn+.btn,[style*=inline-flex] .btn+a.btn,[style*=inline-flex] a.btn+.btn{margin-left:0}.card-body>[id$=-form],.card-body>form{margin:.25rem 0}.card-header{display:flex;align-items:center;gap:.5rem}.card-header h3{margin:0;font-size:1.05rem}.card-header [data-icon]{color:var(--dm-text-muted, #888)}.page-header+p.text-muted{margin:-.5rem 0 1rem}.dm-pe-grid{gap:.75rem 1rem}.dm-pe-grid .form-group{display:flex;flex-direction:column;gap:.25rem;margin:0}.dm-pe-grid .form-group.col-span-2{grid-column:1 / -1}.dm-pe-grid label{font-size:.85rem;font-weight:600;color:var(--dm-text, inherit)}.dm-pe-grid label .required{color:var(--dm-danger, #c33);margin-left:2px}.dm-pe-grid .form-hint{font-size:.75rem;color:var(--dm-text-muted, #888);margin-top:.1rem}.dm-pe-grid .form-input{width:100%;padding:.4rem .6rem}.dm-pe-grid textarea.form-input{resize:vertical;min-height:2.5rem}.dm-menu-badge{display:inline-block;margin-left:6px;padding:1px 7px;font-size:.7em;font-weight:700;line-height:1.4;border-radius:999px;background:var(--dm-text-muted);color:var(--dm-text-inverse, #fff);vertical-align:middle}.menu-tree-row--slim{display:grid;grid-template-columns:16px 1fr auto repeat(9,auto);gap:6px;align-items:center}.menu-tree-row--slim .me-row-label{font-weight:600}.menu-tree-row--slim .me-row-url{color:var(--dm-text-muted);font-size:.85em}.menu-tree-row.me-drop-above{box-shadow:inset 0 2px 0 0 var(--dm-primary)}.menu-tree-row.me-drop-below{box-shadow:inset 0 -2px 0 0 var(--dm-primary)}.menu-tree-row.me-drop-into{background:color-mix(in srgb,var(--dm-primary) 12%,transparent);box-shadow:inset 0 0 0 1px var(--dm-primary);border-radius:var(--dm-radius-sm, 4px)}.menu-tree-row[draggable=true] .menu-tree-grip{cursor:grabbing}.me-pages-picker{display:flex;gap:6px;align-items:center;margin-bottom:6px}.me-pages-picker .form-input{flex:1;min-width:0}.me-colour-control{display:flex;gap:6px;align-items:center}.me-colour-control .me-colour-hex{width:48px;height:32px;padding:2px;flex:none}.me-bind-summary{padding:.6rem .8rem;border:1px solid var(--dm-border, rgba(128, 128, 128, .35));border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-text) 4%,transparent);font-size:.9em}.me-bind-summary code{font-size:.9em}.me-item-form{display:flex;flex-direction:column;gap:10px;padding:4px 2px}.me-item-form .form-field{display:flex;flex-direction:column;gap:4px}.me-item-form .form-label{font-size:.85em;color:var(--dm-text-muted)}.me-form-section{margin:12px 0 2px;padding-top:10px;border-top:1px solid var(--dm-border-light, rgba(0,0,0,.15));font-size:.78em;text-transform:uppercase;letter-spacing:.06em;color:var(--dm-text-muted)}.me-item-form .me-form-section:first-child{margin-top:0;padding-top:0;border-top:0}.me-item-form .me-colour-hex{width:48px;height:32px;padding:2px}.me-item-form .me-field-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.me-item-form .me-field-grid .form-field{margin:0}.me-item-form .me-icon-field{display:flex;align-items:center;gap:8px}.me-item-form .me-icon-field .form-input{flex:1}.me-item-form .me-icon-preview{width:24px;height:24px;flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;color:var(--dm-text-muted)}#admin-sidebar.dm-menu-floating{border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 8px);box-shadow:var(--dm-shadow-lg);overflow:hidden}@media(max-width:768px){#admin-sidebar.dm-menu-floating{position:static!important;inset:auto!important;max-height:none!important;transform:none!important}.dashboard-wrapper{padding-left:0!important;padding-right:0!important}}[data-logic-section]{border:1px solid var(--dm-border);border-radius:var(--dm-radius-md, 6px);padding:.9rem 1rem 1rem;margin-bottom:1rem;background:color-mix(in srgb,var(--dm-text) 3%,transparent)}[data-logic-section] .fb-logic-cond-row,[data-logic-section] .fb-logic-val-rule{padding:.55rem .65rem;border-radius:var(--dm-radius-sm, 4px);border:1px solid var(--dm-border);background:var(--dm-surface, transparent)}[data-logic-section] .fb-logic-cond-row:last-of-type,[data-logic-section] .fb-logic-val-rule:last-of-type{margin-bottom:.75rem}[data-logic-section]>.btn{margin-top:.15rem}[data-logic-section] textarea{min-height:5rem}.project-filter-notice{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap;margin:0 0 1rem;padding:.6rem .85rem;border:1px solid var(--dm-border);border-left:3px solid var(--dm-info, var(--dm-primary));border-radius:var(--dm-radius-md, 6px);background:color-mix(in srgb,var(--dm-info, var(--dm-primary)) 6%,transparent);font-size:.85rem;color:var(--dm-text-muted)}.project-filter-notice a{color:var(--dm-primary);font-weight:600}.ctx-help{display:inline-flex;align-items:center;justify-content:center;width:1.05rem;height:1.05rem;margin-left:.35rem;padding:0;border:1px solid var(--dm-border-dark, var(--dm-border));border-radius:var(--dm-radius-full, 9999px);background:transparent;color:var(--dm-text-muted);font-size:.68rem;font-weight:700;line-height:1;cursor:pointer;vertical-align:middle;flex-shrink:0}.ctx-help:hover,.ctx-help:focus-visible{border-color:var(--dm-primary);color:var(--dm-primary);outline:none}.card-header h3 .ctx-help{align-self:center}.ctx-preview-card{position:sticky;top:1rem}@media(max-width:1100px){.ctx-workspace>.col-8,.ctx-workspace>.col-4{flex:0 0 auto;width:100%}.ctx-preview-card{position:static}}.dm-plugin-shell{border:1px solid var(--dm-border);border-radius:var(--dm-radius-lg, 12px);background:var(--dm-surface);overflow:hidden}.dm-plugin-banner{display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;padding:.7rem 1rem;background:var(--dm-background-alt);border-bottom:1px solid var(--dm-border)}.dm-plugin-banner-identity{display:flex;align-items:center;gap:.55rem;min-width:0;margin-right:auto}.dm-plugin-banner-actions{display:flex;align-items:center;gap:.25rem;flex:0 0 auto;margin-right:.15rem;padding-right:.4rem;border-right:1px solid var(--dm-border)}.dm-plugin-banner-action{padding:.25rem .4rem;line-height:1;color:var(--dm-text-muted)}.dm-plugin-banner-action:hover{color:var(--dm-primary)}.dm-plugin-banner-icon{display:inline-flex;align-items:center;color:var(--dm-primary);flex:0 0 auto}.dm-plugin-banner-name{margin:0;font-size:1.05rem;font-weight:600;letter-spacing:.01em;color:var(--dm-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dm-activity{display:inline-flex;align-items:center;flex:0 0 auto;margin-left:.35rem}.dm-activity domma-loader{display:inline-block;line-height:0}.dm-activity[hidden]{display:none!important}#admin-topbar>.dm-activity{margin:0 .75rem 0 0}.dm-plugin-banner-parent{font-weight:500;color:var(--dm-text-muted)}.dm-plugin-banner-sep{margin:0 .4rem;color:var(--dm-text-muted)}.dm-plugin-banner-facts{display:flex;align-items:center;gap:.4rem;flex-wrap:wrap}.dm-plugin-fact{font-size:.72rem;line-height:1.6;padding:1px 8px;border-radius:999px;border:1px solid var(--dm-border);color:var(--dm-text-muted);background:var(--dm-surface);white-space:nowrap}.dm-plugin-fact--version{font-variant-numeric:tabular-nums;color:var(--dm-text)}.dm-plugin-fact--licence.is-included{color:var(--dm-info);border-color:var(--dm-info)}.dm-plugin-fact--licence.is-licensed{color:var(--dm-success);border-color:var(--dm-success)}.dm-plugin-fact--expiry{font-variant-numeric:tabular-nums}.dm-plugin-fact--expiry.is-warning{color:var(--dm-warning);border-color:var(--dm-warning)}.dm-plugin-fact--expiry.is-danger{color:var(--dm-danger);border-color:var(--dm-danger)}.dm-plugin-body{padding:1rem}.dm-plugin-body .card.dm-plugin-unwrap{border:0;background:none;box-shadow:none;margin:0}.dm-plugin-body .card.dm-plugin-unwrap>.card-body{padding:0}.dm-plugin-body>.p-4{padding:0}@media(max-width:640px){.dm-plugin-banner{align-items:flex-start;flex-direction:column;gap:.5rem}.dm-plugin-body{padding:.75rem}}.analytics-filter{max-width:240px}.dm-help-btn{display:inline-flex;align-items:center;justify-content:center;vertical-align:middle;margin-left:.3rem;padding:0;border:0;background:none;line-height:1;color:var(--dm-text-muted);opacity:.75;cursor:pointer}.dm-help-btn:hover,.dm-help-btn:focus-visible{color:var(--dm-primary);opacity:1}.dm-help-pop{position:fixed;z-index:10050;max-width:300px;padding:.6rem .75rem;background:var(--dm-surface, var(--dm-background));color:var(--dm-text);border:1px solid var(--dm-border);border-radius:var(--dm-radius-md);box-shadow:0 8px 24px #00000040;font-size:.85rem;line-height:1.45;text-align:left;font-weight:400}.dm-help-pop strong{display:block;margin-bottom:.2rem;font-weight:600}.dm-help-pop p{margin:0}.dm-admin-sidebar .sidebar-badge[data-badge-label]{cursor:help}.dm-admin-sidebar .sidebar-badge--danger,.dm-admin-sidebar .sidebar-link.active .sidebar-badge--danger{background:var(--dm-danger, #dc2626);color:#fff}.dm-admin-sidebar .sidebar-badge--live{background:color-mix(in srgb,var(--dm-success, #16a34a) 20%,transparent);color:var(--dm-success, #16a34a)}#admin-sidebar .sidebar-badge--live{padding-left:1.05rem!important}#admin-sidebar summary>.sidebar-badge--sum{position:static!important;transform:none!important;margin-left:auto!important;margin-right:.45rem;text-transform:none;letter-spacing:0}#admin-sidebar summary:has(>.sidebar-badge--sum):after{margin-left:0}#admin-sidebar details[open]>summary>.sidebar-badge--sum{display:none}.dm-admin-sidebar .sidebar-badge--live:before{content:"";position:absolute;left:.4rem;top:50%;width:.4rem;height:.4rem;margin-top:-.2rem;border-radius:50%;background:currentColor;animation:dm-badge-pulse 2s ease-in-out infinite}@keyframes dm-badge-pulse{50%{opacity:.35}}@media(prefers-reduced-motion:reduce){.dm-admin-sidebar .sidebar-badge--live:before{animation:none}}.dm-badge-pop{width:290px;max-width:calc(100vw - 16px);font-size:.8rem;pointer-events:none}.dm-badge-pop-title{display:block;font-size:.85rem}.dm-badge-pop-sub{margin:.1rem 0 .5rem;color:var(--dm-text-muted)}.dm-badge-pop-rows{display:grid;grid-template-columns:1fr auto;gap:.2rem .75rem;margin:0 0 .5rem;padding:.45rem 0;border-top:1px solid var(--dm-border);border-bottom:1px solid var(--dm-border)}.dm-badge-pop-rows dt{color:var(--dm-text-muted)}.dm-badge-pop-rows dd{margin:0;text-align:right;font-weight:600;font-variant-numeric:tabular-nums}.dm-badge-pop-items{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:.4rem}.dm-badge-pop-items li{display:flex;flex-direction:column;min-width:0}.dm-badge-pop-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dm-badge-pop-meta{font-size:.72rem;color:var(--dm-text-muted)}.dm-badge-pop .is-danger,.dm-badge-pop li.is-danger .dm-badge-pop-meta{color:var(--dm-danger, #dc2626)}.dm-badge-pop-empty{margin:0;color:var(--dm-text-muted)}.dm-plugin-body,.dm-slideover{font-family:var(--dm-font-sans, inherit)}.dm-plugin-body :is(button,input,select,textarea,.btn,.form-input,.form-select,.form-textarea),.dm-slideover :is(button,input,select,textarea,.btn,.form-input,.form-select,.form-textarea){font-family:inherit}.dm-plugin-body :is(input,select,textarea,.form-input,.form-select,.form-textarea):not(.form-input-lg),.dm-slideover :is(input,select,textarea,.form-input,.form-select,.form-textarea):not(.form-input-lg){font-size:var(--dm-text-sm)}.dm-plugin-body .form-input-lg{font-size:var(--dm-text-base)}.dm-plugin-body :is(.font-mono,code,pre,kbd),.dm-slideover :is(.font-mono,code,pre,kbd){font-family:var(--dm-font-mono, monospace)}.dm-plugin-body .form-label,.dm-slideover .form-label{font-size:var(--dm-text-sm)}.related-box{display:flex;flex-direction:column;gap:.6rem;border-top:1px solid var(--dm-border);padding-top:.75rem}.related-box[hidden]{display:none}.related-head{display:flex;align-items:center;gap:.4rem}.related-head strong{flex:1}.related-list{list-style:none;margin:.3rem 0 0;padding:0;display:flex;flex-direction:column;gap:.25rem}.related-list li{display:flex;flex-direction:column}.related-list li.is-past{opacity:.7}.related-meta,.related-note,.related-loading{color:var(--dm-text-muted);font-size:var(--dm-text-xs);margin:0}.login-challenge{display:flex;flex-direction:column;gap:.6rem;margin-bottom:.75rem}.login-challenge #challenge-code{font-size:1.25rem;letter-spacing:.2em;text-align:center}.login-trust{display:flex;align-items:center;gap:.45rem;font-size:.875rem}.login-enrol{display:flex;flex-direction:column;align-items:center;gap:.6rem;margin-bottom:1rem}.login-enrol img{background:#fff;padding:.5rem;border-radius:.5rem}.login-secret{display:flex;flex-direction:column;align-items:center;gap:.25rem;text-align:center}.login-secret code{font-size:.95rem;letter-spacing:.08em;word-break:break-all}.usx-secret{display:flex;flex-direction:column;gap:.75rem;padding:1rem}.usx-secret-link{display:block;padding:.6rem .75rem;border:1px solid var(--dm-border);border-radius:var(--dm-radius-sm);background:var(--dm-background-alt);word-break:break-all;font-size:.8rem;user-select:all}.usx-secret-actions{display:flex;align-items:center;justify-content:flex-end;gap:.5rem}.usx-secret-actions .btn{display:inline-flex;align-items:center;gap:.35rem}.usx-secret-note{margin-right:auto;color:var(--dm-success)}.usx-secret-foot{color:var(--dm-text-muted);font-size:.8rem;margin:0}.uex-reset{display:flex;flex-wrap:wrap;align-items:center;gap:.35rem .5rem;margin-top:.5rem}.uex-reset>small:first-child{flex-basis:100%;color:var(--dm-text-muted)}.uex-reset .btn{display:inline-flex;align-items:center;gap:.35rem}.uex-reset-open{flex-basis:100%;color:var(--dm-info)}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export function isAuthScreen(e){return/^\/(login|reset-password)(\?|$)/.test(String(e??""))}const i=/^\/(login|reset-password)(\/|\?|$)/,
|
|
1
|
+
export function isAuthScreen(e){return/^\/(login|reset-password)(\?|$)/.test(String(e??""))}const i=/^\/(login|reset-password)(\/|\?|$)/,c=/^\/[A-Za-z0-9\-._~%/?=&:@+,]*$/;export function safeReturnPath(e){const t=String(e??"").trim();return!t||t.length>500||t.startsWith("//")||!c.test(t)||t==="/"||i.test(t)?"":t}export function loginHashFor(e,{expired:t=!1}={}){const n=safeReturnPath(String(e??"").replace(/^#/,"")),r=[n&&`next=${encodeURIComponent(n)}`,t&&"why=expired"].filter(Boolean);return`#/login${r.length?`?${r.join("&")}`:""}`}export function readLoginHash(e){const t=String(e??"").split("?")[1]||"",n=new URLSearchParams(t);let r="";try{r=safeReturnPath(n.get("next")||"")}catch{r=""}return{next:r||"/",expired:n.get("why")==="expired"}}export function resetTokenFrom(e){const t=String(e??"").match(/[?&]token=([a-f0-9]{64})(?:&|$)/);return t?t[1]:""}export function passwordVerdict({password:e="",confirm:t="",problems:n=[]}){const r=[];e&&e.length<8&&r.push("At least 8 characters.");for(const o of n||[])o&&!/at least 8 characters/i.test(o)&&r.push(String(o));const s=!!t&&t!==e;return{ok:e.length>=8&&!r.length&&t===e,messages:r,mismatch:s}}export function minutesText(e){const t=Math.max(1,Math.round(Number(e)||0));if(t<55)return`${t} minute${t===1?"":"s"}`;const n=Math.round(t/60);return n<48?`${n} hour${n===1?"":"s"}`:`${Math.round(n/24)} days`}export function challengeText(e,t){const n=e||{};return t==="recovery"?{label:"Recovery code",help:"Type one of the recovery codes you saved when you set this up. Each works once.",inputMode:"text"}:{label:n.label||"Code",help:n.help||"Enter the code to finish signing in.",inputMode:n.inputMode==="text"?"text":"numeric"}}
|
|
@@ -1,134 +1,156 @@
|
|
|
1
|
+
<!-- Sign in, first-time setup, onboarding and password reset. Bound with M.applyBindings - see
|
|
2
|
+
admin/js/views/login.js: `step` says which panel shows, and nothing here is shown or hidden by hand.
|
|
3
|
+
The F.render forms (setup, login, forgot, reset) are drawn into their containers as before. -->
|
|
1
4
|
<div class="login-wrap">
|
|
2
5
|
<div class="login-card card">
|
|
3
6
|
<div class="card-body">
|
|
4
7
|
<div class="login-logo">
|
|
5
8
|
<span data-icon="layout"></span>
|
|
6
|
-
<h1
|
|
9
|
+
<h1 data-bind-text="siteTitle.value">Domma CMS</h1>
|
|
7
10
|
</div>
|
|
8
11
|
|
|
9
12
|
<!-- ── Step 1: Setup (first-time - no users exist) ── -->
|
|
10
|
-
<div
|
|
13
|
+
<div hidden data-bind-hidden="step.value !== 'setup'">
|
|
11
14
|
<h2 class="login-heading">Welcome - create your admin account</h2>
|
|
12
15
|
<p class="text-muted mb-4">This is a first-time setup. Set up your administrator account to get started.</p>
|
|
13
16
|
<div id="setup-form-container"></div>
|
|
14
17
|
</div>
|
|
15
18
|
|
|
16
19
|
<!-- ── Step 2: Onboarding - Site Identity ── -->
|
|
17
|
-
<div
|
|
20
|
+
<div hidden data-bind-hidden="step.value !== 'onboarding-site'">
|
|
18
21
|
<h2 class="login-heading">Name your site</h2>
|
|
19
22
|
<p class="text-muted mb-4">You can change these any time from Settings.</p>
|
|
20
23
|
<div class="mb-3">
|
|
21
24
|
<label class="form-label" for="ob-title">Site title</label>
|
|
22
|
-
<input type="text" id="ob-title" class="form-input" placeholder="My Blog">
|
|
25
|
+
<input type="text" id="ob-title" class="form-input" placeholder="My Blog" data-model="obTitle.value">
|
|
23
26
|
</div>
|
|
24
27
|
<div class="mb-4">
|
|
25
28
|
<label class="form-label" for="ob-tagline">Tagline <span class="text-muted">(optional)</span></label>
|
|
26
|
-
<input type="text" id="ob-tagline" class="form-input" placeholder="A short description of your site">
|
|
29
|
+
<input type="text" id="ob-tagline" class="form-input" placeholder="A short description of your site" data-model="obTagline.value">
|
|
27
30
|
</div>
|
|
28
|
-
<div
|
|
29
|
-
<button
|
|
30
|
-
|
|
31
|
+
<div class="alert alert-danger mb-3" hidden data-bind-hidden="!obSiteError.value" data-bind-text="obSiteError.value"></div>
|
|
32
|
+
<button class="btn btn-primary btn-block" data-on-click="saveSite" data-bind-disabled="obSiteSaving.value"
|
|
33
|
+
data-bind-text="obSiteSaving.value ? 'Saving…' : 'Continue'">Continue</button>
|
|
34
|
+
<a href="#" class="btn-skip" data-on-click="skipSite">Skip this step</a>
|
|
31
35
|
</div>
|
|
32
36
|
|
|
33
37
|
<!-- ── Step 3: Onboarding - Theme Picker ── -->
|
|
34
|
-
<div
|
|
38
|
+
<div hidden data-bind-hidden="step.value !== 'onboarding-theme'">
|
|
35
39
|
<h2 class="login-heading">Choose a theme</h2>
|
|
36
40
|
<p class="text-muted mb-3">Pick the colour scheme for your site. You can change it later.</p>
|
|
37
|
-
<div class="theme-grid"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
<div class="theme-grid" data-each="themes key=id">
|
|
42
|
+
<div class="theme-swatch" data-bind-title="id" data-on-click="$parent.pickTheme(id)"
|
|
43
|
+
data-bind-class="$parent.theme.value === id && 'selected'">
|
|
44
|
+
<div class="theme-swatch-preview" data-bind-style-background="bg">
|
|
45
|
+
<div class="theme-swatch-accent" data-bind-style-background="primary"></div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="theme-swatch-label">
|
|
48
|
+
<span data-bind-text="label"></span>
|
|
49
|
+
<span class="theme-swatch-mode" data-bind-text="dark ? 'Dark' : 'Light'"></span>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="alert alert-danger mb-3" hidden data-bind-hidden="!obThemeError.value" data-bind-text="obThemeError.value"></div>
|
|
54
|
+
<button class="btn btn-primary btn-block mt-3" data-on-click="saveTheme" data-bind-disabled="obThemeSaving.value"
|
|
55
|
+
data-bind-text="obThemeSaving.value ? 'Applying…' : 'Apply theme'">Apply theme</button>
|
|
56
|
+
<a href="#" class="btn-skip" data-on-click="skipTheme">Skip this step</a>
|
|
41
57
|
</div>
|
|
42
58
|
|
|
43
59
|
<!-- ── Step 4: Onboarding - Done ── -->
|
|
44
|
-
<div
|
|
60
|
+
<div hidden data-bind-hidden="step.value !== 'onboarding-done'">
|
|
45
61
|
<div class="ob-done-icon"><span data-icon="check-circle"></span></div>
|
|
46
62
|
<h2 class="login-heading">You're all set!</h2>
|
|
47
63
|
<p class="text-muted mb-4">Your site is ready. Head to the dashboard to start creating content.</p>
|
|
48
|
-
<a href="#/"
|
|
64
|
+
<a href="#/" class="btn btn-primary btn-block">Go to dashboard</a>
|
|
49
65
|
</div>
|
|
50
66
|
|
|
51
67
|
<!-- ── Login mode ── -->
|
|
52
|
-
<div
|
|
68
|
+
<div hidden data-bind-hidden="step.value !== 'login'">
|
|
53
69
|
<h2 class="login-heading">Sign in</h2>
|
|
54
|
-
<div
|
|
70
|
+
<div class="alert alert-info mb-3" hidden data-bind-hidden="!notice.value" data-bind-text="notice.value"></div>
|
|
55
71
|
<div id="login-form-container"></div>
|
|
56
|
-
<a href="#"
|
|
72
|
+
<a href="#" class="btn-skip" data-on-click="forgot">Forgot your password?</a>
|
|
57
73
|
</div>
|
|
58
74
|
|
|
59
|
-
<!-- ── A sign-in check asks one more thing (two-factor) ── -->
|
|
60
|
-
<div
|
|
61
|
-
<h2 class="login-heading"
|
|
62
|
-
<p class="text-muted mb-4"
|
|
63
|
-
<div class="login-enrol"
|
|
64
|
-
|
|
75
|
+
<!-- ── A sign-in check asks one more thing (two-factor). Plugin text: bound as text, never markup. ── -->
|
|
76
|
+
<div hidden data-bind-hidden="step.value !== 'challenge'">
|
|
77
|
+
<h2 class="login-heading" data-bind-text="chTitle.value">One more step</h2>
|
|
78
|
+
<p class="text-muted mb-4" data-bind-text="chHelp.value"></p>
|
|
79
|
+
<div class="login-enrol" hidden data-bind-hidden="!(chSecret.value || chImage.value)">
|
|
80
|
+
<!-- Only a data: image reaches here (the server checks it). -->
|
|
81
|
+
<img alt="QR code to scan with your authenticator app" width="180" height="180"
|
|
82
|
+
hidden data-bind-hidden="!chImage.value" data-bind-src="chImage.value">
|
|
65
83
|
<div class="login-secret">
|
|
66
84
|
<small class="text-muted">Or type this key into the app:</small>
|
|
67
|
-
<code
|
|
68
|
-
<button type="button" class="btn btn-xs btn-ghost"
|
|
85
|
+
<code data-bind-text="chSecret.value"></code>
|
|
86
|
+
<button type="button" class="btn btn-xs btn-ghost" data-on-click="copySecret">Copy</button>
|
|
69
87
|
</div>
|
|
70
88
|
</div>
|
|
71
|
-
<form
|
|
72
|
-
<label class="form-label" for="challenge-code"
|
|
73
|
-
<input id="challenge-code" class="form-input" autocomplete="one-time-code"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<
|
|
89
|
+
<form class="login-challenge" autocomplete="off" data-on-submit="verify">
|
|
90
|
+
<label class="form-label" for="challenge-code" data-bind-text="chLabel.value">Code</label>
|
|
91
|
+
<input id="challenge-code" class="form-input" autocomplete="one-time-code" maxlength="64" required
|
|
92
|
+
data-model="chCode.value" data-bind-inputmode="chInputMode.value">
|
|
93
|
+
<label class="login-trust" hidden data-bind-hidden="!chCanTrust.value">
|
|
94
|
+
<input type="checkbox" data-model="chTrust.value"> Trust this browser for 30 days</label>
|
|
95
|
+
<p class="text-danger" hidden data-bind-hidden="!chError.value" data-bind-text="chError.value"></p>
|
|
96
|
+
<button type="submit" class="btn btn-primary btn-block" data-bind-disabled="chBusy.value">Verify</button>
|
|
78
97
|
</form>
|
|
79
|
-
<a href="#"
|
|
80
|
-
|
|
98
|
+
<a href="#" class="btn-skip" hidden data-bind-hidden="!chCanRecover.value" data-on-click="toggleRecovery"
|
|
99
|
+
data-bind-text="chMode.value === 'recovery' ? 'Use the code instead' : 'Use a recovery code instead'">Use a recovery code instead</a>
|
|
100
|
+
<a href="#" class="btn-skip" data-on-click="backToLogin">Back to sign in</a>
|
|
81
101
|
</div>
|
|
82
102
|
|
|
83
103
|
<!-- ── Forgot password ── -->
|
|
84
|
-
<div
|
|
104
|
+
<div hidden data-bind-hidden="step.value !== 'forgot'">
|
|
85
105
|
<h2 class="login-heading">Reset your password</h2>
|
|
86
|
-
<p class="text-muted mb-4"
|
|
87
|
-
<div id="forgot-form-container"></div>
|
|
88
|
-
|
|
106
|
+
<p class="text-muted mb-4" data-bind-hidden="!byEmail.value">Enter your email and we'll send you a reset link.</p>
|
|
107
|
+
<div id="forgot-form-container" data-bind-hidden="!byEmail.value"></div>
|
|
108
|
+
<!-- No mail server: say so rather than promise an email that never comes. -->
|
|
109
|
+
<div class="alert alert-warning mb-3" hidden data-bind-hidden="byEmail.value">
|
|
89
110
|
This site cannot send email, so it cannot send you a reset link. Ask an administrator to set a
|
|
90
111
|
new password for you.
|
|
91
112
|
</div>
|
|
92
|
-
<a href="#"
|
|
113
|
+
<a href="#" class="btn-skip" data-on-click="backToLogin">Back to sign in</a>
|
|
93
114
|
</div>
|
|
94
115
|
|
|
95
116
|
<!-- ── Forgot success ── -->
|
|
96
|
-
<div
|
|
117
|
+
<div hidden data-bind-hidden="step.value !== 'forgot-success'">
|
|
97
118
|
<div class="ob-done-icon"><span data-icon="mail"></span></div>
|
|
98
119
|
<h2 class="login-heading">Check your email</h2>
|
|
99
120
|
<p class="text-muted mb-4">If an account exists for that address, we've sent a reset link. It expires in
|
|
100
|
-
<span
|
|
101
|
-
<a href="#"
|
|
121
|
+
<span data-bind-text="expiresIn.value || '1 hour'">1 hour</span>. Nothing there? Check your spam folder.</p>
|
|
122
|
+
<a href="#" class="btn btn-primary btn-block" data-on-click="backToLogin">Back to sign in</a>
|
|
102
123
|
</div>
|
|
103
124
|
|
|
104
125
|
<!-- ── Reset password (the link was checked on opening) ── -->
|
|
105
|
-
<div
|
|
126
|
+
<div hidden data-bind-hidden="step.value !== 'reset'">
|
|
106
127
|
<h2 class="login-heading">Set a new password</h2>
|
|
107
|
-
<p class="text-muted mb-3">For <strong
|
|
108
|
-
expires in <span
|
|
128
|
+
<p class="text-muted mb-3">For <strong data-bind-text="resetEmail.value"></strong>. This link works once and
|
|
129
|
+
expires in <span data-bind-text="resetExpiry.value"></span>.</p>
|
|
109
130
|
<div id="reset-form-container"></div>
|
|
110
|
-
<ul
|
|
131
|
+
<ul class="login-rules" aria-live="polite" hidden data-bind-hidden="!resetLines.value.length"
|
|
132
|
+
data-each="resetLines.value key=id"><li data-bind-text="text"></li></ul>
|
|
111
133
|
<p class="text-muted login-small">Setting it signs this account out everywhere else, and we email you
|
|
112
134
|
to say it changed.</p>
|
|
113
135
|
</div>
|
|
114
136
|
|
|
115
137
|
<!-- ── The reset link is no good ── -->
|
|
116
|
-
<div
|
|
138
|
+
<div hidden data-bind-hidden="step.value !== 'reset-invalid'">
|
|
117
139
|
<div class="ob-done-icon login-icon-warn"><span data-icon="clock"></span></div>
|
|
118
140
|
<h2 class="login-heading">This link has expired or was already used</h2>
|
|
119
141
|
<p class="text-muted mb-4">Reset links work once and only for a limited time. A newer link also
|
|
120
142
|
replaces an older one - use the most recent email. You can ask for a new link now.</p>
|
|
121
|
-
<a href="#"
|
|
122
|
-
<a href="
|
|
143
|
+
<a href="#" class="btn btn-primary btn-block" data-on-click="newLink">Send me a new link</a>
|
|
144
|
+
<a href="#/login" class="btn-skip">Back to sign in</a>
|
|
123
145
|
</div>
|
|
124
146
|
|
|
125
147
|
<!-- ── Reset done ── -->
|
|
126
|
-
<div
|
|
148
|
+
<div hidden data-bind-hidden="step.value !== 'reset-done'">
|
|
127
149
|
<div class="ob-done-icon"><span data-icon="check-circle"></span></div>
|
|
128
150
|
<h2 class="login-heading">Password changed</h2>
|
|
129
151
|
<p class="text-muted mb-4">Sign in with your new password. Anywhere else you were signed in has been
|
|
130
152
|
signed out.</p>
|
|
131
|
-
<a href="#"
|
|
153
|
+
<a href="#" class="btn btn-primary btn-block" data-on-click="signInAfterReset">Sign in</a>
|
|
132
154
|
</div>
|
|
133
155
|
</div>
|
|
134
156
|
</div>
|
package/admin/js/views/login.js
CHANGED
|
@@ -1,11 +1 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
<div class="theme-swatch" data-theme="${s.id}" title="${s.id}">
|
|
3
|
-
<div class="theme-swatch-preview" style="background:${s.bg}">
|
|
4
|
-
<div class="theme-swatch-accent" style="background:${s.primary}"></div>
|
|
5
|
-
</div>
|
|
6
|
-
<div class="theme-swatch-label">
|
|
7
|
-
<span>${s.label}</span>
|
|
8
|
-
<span class="theme-swatch-mode">${s.dark?"Dark":"Light"}</span>
|
|
9
|
-
</div>
|
|
10
|
-
</div>
|
|
11
|
-
`);o.on("click",()=>{e.find(".theme-swatch").removeClass("selected"),o.addClass("selected")}),t.append(o)}),e.find('[data-theme="charcoal-dark"]').addClass("selected")}function L(e){e.find("#ob-theme-skip").on("click",t=>{t.preventDefault(),a(e,"onboarding-done")}),e.find("#ob-theme-btn").on("click",async()=>{e.find("#ob-theme-error").hide();const t=e.find(".theme-swatch.selected").attr("data-theme")||"charcoal-dark",s=e.find("#ob-theme-btn").prop("disabled",!0).text("Applying\u2026");try{const o=await l.settings.get();await l.settings.save({...o,theme:t}),a(e,"onboarding-done")}catch(o){D(e,"ob-theme-error",o.message||"Could not save theme.")}finally{s.prop("disabled",!1).text("Apply theme")}})}function A(e,{next:t="/",email:s=""}={}){F.render("#login-form-container",K,s?{email:s}:{},{layout:"stacked",submitText:"Sign in",onSubmit:async o=>{try{const r=await l.auth.login(o);if(r?.challenge&&r.ticket)return q(e,r,t),!1;b(r),k(t)}catch(r){return E.toast(r.message||"Invalid credentials. Please try again.",{type:"error"}),!1}}}),v(e.find("#login-form-container").get(0)),e.find("#forgot-link").off("click").on("click",o=>{o.preventDefault();const r=String(e.find('#login-form-container input[type="email"]').val()||"").trim();a(e,"forgot"),B(e,r)})}function q(e,{challenge:t,ticket:s},o="/"){let r="code";const c=e.find("#challenge-code"),p=e.find("#challenge-error"),u=e.find("#challenge-recovery"),m=f=>{r=f;const i=f==="recovery";e.find("#challenge-label").text(i?"Recovery code":t.label||"Code"),e.find("#challenge-help").text(i?"Type one of the recovery codes you saved when you set this up. Each works once.":t.help||"Enter the code to finish signing in."),c.val("").attr("inputmode",i||t.inputMode==="text"?"text":"numeric"),u.text(i?"Use the code instead":"Use a recovery code instead"),p.hide(),setTimeout(()=>c.trigger("focus"),0)};e.find("#challenge-title").text(t.title||"One more step");const y=!!(t.image||t.secret);e.find("#challenge-enrol").css("display",y?"":"none");const h=e.find("#challenge-image").get(0);h&&(h.src=t.image||"",h.style.display=t.image?"":"none"),e.find("#challenge-secret").text(t.secret||""),e.find("#challenge-copy").off("click").on("click",async()=>{try{await navigator.clipboard.writeText(String(t.secret||"").replace(/\s+/g,"")),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - select the key and copy it.",{type:"warning"})}}),e.find("#challenge-trust-wrap").css("display",t.trust===!0?"":"none"),u.css("display",t.recovery===!0?"":"none"),m("code"),a(e,"challenge"),u.off("click").on("click",f=>{f.preventDefault(),m(r==="code"?"recovery":"code")}),e.find("#challenge-back").off("click").on("click",f=>{f.preventDefault(),a(e,"login")}),e.find("#challenge-form").off("submit").on("submit",async f=>{f.preventDefault();const i=e.find("#challenge-submit").prop("disabled",!0);try{const d=await l.auth.verifyLogin({ticket:s,response:String(c.val()||"").trim(),mode:r,trust:e.find("#challenge-trust").prop("checked")===!0});if(d?.challenge&&d.ticket){q(e,d,o);return}b(d),k(o)}catch(d){const n=d.message||"That did not match.";p.text(n).show(),/sign in again/i.test(n)&&setTimeout(()=>a(e,"login"),1800)}finally{i.prop("disabled",!1)}})}function B(e,t=""){const s=o=>{o.preventDefault(),a(e,"login")};if(e.find("#forgot-back-link").off("click").on("click",s),e.find("#forgot-success-back-link").off("click").on("click",s),!g.byEmail){e.find("#forgot-intro").hide(),e.find("#forgot-form-container").empty(),e.find("#forgot-unavailable").show();return}e.find("#forgot-intro").show(),e.find("#forgot-unavailable").hide(),F.render("#forgot-form-container",z,t?{email:t}:{},{layout:"stacked",submitText:"Send reset link",onSubmit:async o=>{try{const r=await l.auth.forgotPassword(o.email);r?.expiresIn&&(g.expiresIn=r.expiresIn)}catch{}g.expiresIn&&e.find("#forgot-success-expiry").text(g.expiresIn),a(e,"forgot-success"),Domma.icons.scan()}})}async function ee(e,t){if(!t){w(e);return}const s=E.loader(e.get(0),{type:"dots"});let o;l.auth.setupStatus().then(r=>x(e,r)).catch(()=>{});try{o=await l.auth.checkReset(t)}catch{w(e);return}finally{s.destroy()}e.find("#reset-email").text(o.email||""),e.find("#reset-expiry").text(W(o.minutesLeft)),a(e,"reset"),te(e,t,o.email||"")}function w(e){a(e,"reset-invalid"),e.find("#reset-invalid-back").off("click").on("click",t=>{t.preventDefault(),window.location.hash="#/login"}),e.find("#reset-invalid-new").off("click").on("click",async t=>{t.preventDefault();try{x(e,await l.auth.setupStatus())}catch{}a(e,"forgot"),B(e)})}function te(e,t,s){let o=[],r=0,c=null;const p=e.find("#reset-rules"),u=()=>({password:String(e.find('#reset-form-container input[name="password"]').val()||""),confirm:String(e.find('#reset-form-container input[name="confirmPassword"]').val()||"")}),m=()=>{const{password:i,confirm:d}=u(),n=Y({password:i,confirm:d,problems:o}),S=[...n.messages,...n.mismatch?["The two passwords do not match yet."]:[]],T=p.get(0);T.textContent="";for(const O of S){const C=document.createElement("li");C.textContent=O,T.appendChild(C)}return p.css("display",S.length&&i?"":"none"),n},y=async()=>{const{password:i}=u(),d=++r;if(i.length<8){o=[],m();return}try{const n=await l.auth.checkReset(t,i);d===r&&(o=n.problems||[])}catch(n){if(n.body?.expired){w(e);return}}d===r&&m()};F.render("#reset-form-container",J,{},{layout:"stacked",submitText:"Set new password",onSubmit:async i=>{await y();const d=m();if(!d.ok)return E.toast(d.mismatch?"The passwords do not match.":"That password does not meet the rules below.",{type:"error"}),!1;try{await l.auth.resetPassword(t,i.password),history.replaceState(null,"","#/reset-password"),a(e,"reset-done"),e.find("#reset-done-signin").off("click").on("click",n=>{n.preventDefault(),a(e,"login"),A(e,{email:s})})}catch(n){return n.body?.expired?(w(e),!1):(o=n.body?.problems||[],m(),E.toast(n.message||"The password was not changed.",{type:"error"}),!1)}}});const h=e.find("#reset-form-container form").get(0)||e.find("#reset-form-container").get(0);if(h&&s){const i=document.createElement("input");i.type="email",i.name="username",i.autocomplete="username",i.value=s,i.readOnly=!0,i.hidden=!0,h.prepend(i)}v(e.find("#reset-form-container").get(0));const f=i=>{i.target.matches?.('input[type="password"], input[name="password"], input[name="confirmPassword"]')&&(i.target.name==="password"&&(o=[]),m(),clearTimeout(c),i.target.name==="password"&&(c=setTimeout(y,400)))};e.find("#reset-form-container").get(0)?.addEventListener("input",f)}
|
|
1
|
+
import{api as i,isAuthenticated as B,setAuthData as f,takePendingLogout as A}from"../api.js";import{themesInDisplayOrder as D}from"../lib/themes.js";import{enhancePasswordFields as g}from"../lib/password-field.js";import{challengeText as b,minutesText as O,passwordVerdict as j,readLoginHash as H,resetTokenFrom as N}from"../lib/login-arrange.js";import{viewLifetime as V}from"../lib/tool-kit.js";const W={name:{type:"string",required:!0,minLength:2,label:"Full Name",formConfig:{placeholder:"Your name",autocomplete:"name"}},email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"admin@example.com",autocomplete:"email"}},password:{type:"password",required:!0,minLength:8,label:"Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password",tooltip:"Minimum 8 characters"}}},Y={email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"you@example.com",autocomplete:"email"}},password:{type:"password",required:!0,label:"Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"current-password"}}},K={email:{type:"email",required:!0,label:"Email Address",formConfig:{placeholder:"you@example.com",autocomplete:"email"}}},U={password:{type:"password",required:!0,minLength:8,label:"New Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password"}},confirmPassword:{type:"password",required:!0,label:"Confirm Password",formConfig:{placeholder:"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",autocomplete:"new-password"}}};let S=Promise.resolve();function z(){let e;S=new Promise(o=>{e=o});const t=M.subscribe("router:afterChange",()=>{a(),e()}),a=()=>{typeof t=="function"&&t()};setTimeout(()=>{a(),e()},1500)}function y(e){S.then(()=>R.navigate(e))}const d=V(/^#\/(login|reset-password)(\?|$)/);function G(){const e=M.observable(null),t=M.observable("code");return{step:M.observable(""),siteTitle:M.observable("Domma CMS"),notice:M.observable(""),byEmail:M.observable(!0),expiresIn:M.observable(""),obTitle:M.observable(""),obTagline:M.observable(""),obSiteError:M.observable(""),obSiteSaving:M.observable(!1),themes:D(),theme:M.observable("charcoal-dark"),obThemeError:M.observable(""),obThemeSaving:M.observable(!1),chChallenge:e,chMode:t,chTitle:M.computed(()=>e.value?.title||"One more step"),chLabel:M.computed(()=>b(e.value,t.value).label),chHelp:M.computed(()=>b(e.value,t.value).help),chInputMode:M.computed(()=>b(e.value,t.value).inputMode),chImage:M.computed(()=>e.value?.image||""),chSecret:M.computed(()=>e.value?.secret||""),chCanTrust:M.computed(()=>e.value?.trust===!0),chCanRecover:M.computed(()=>e.value?.recovery===!0),chCode:M.observable(""),chTrust:M.observable(!1),chError:M.observable(""),chBusy:M.observable(!1),resetEmail:M.observable(""),resetExpiry:M.observable(""),resetLines:M.observable([])}}function w(e,t={}){e.byEmail.value=t.resetByEmail!==!1,e.expiresIn.value=t.resetExpiresIn||"",t.siteTitle&&(e.siteTitle.value=t.siteTitle)}export const loginView={templateUrl:"/admin/js/templates/login.html",async onMount(e){d.reset(),z();const t=A(),a=window.location.hash,{next:o,expired:s}=H(a);if(B()){y(a.startsWith("#/reset-password")?"/":o);return}const n=e.get(0).querySelector(".login-wrap")||e.get(0),c=G(),l={state:c,root:n,next:o,token:"",email:"",pw:{problems:[],asked:0,timer:null}},p=M.applyBindings(c,n,{methods:J(l)});if(d.add(()=>p.dispose()),d.add(()=>clearTimeout(l.pw.timer)),d.add(M.effect(()=>{c.step.value,requestAnimationFrame(()=>{n.isConnected&&I.scan(n)})})),a.startsWith("#/reset-password")){await $(l,N(a));return}let h=!1;const v=E.loader(e.get(0),{type:"dots"});try{const r=await i.auth.setupStatus();h=r.needsSetup,w(c,r)}catch{E.toast("Could not reach the server.",{type:"error"})}finally{v.destroy()}h?(c.step.value="setup",Q(l)):(c.step.value="login",T(l),s&&!t&&(c.notice.value="Your session ended - sign in again to carry on where you were."))}};function J(e){const{state:t}=e,a=o=>s=>{s?.preventDefault?.(),o()};return{skipSite:a(()=>{t.step.value="onboarding-theme"}),saveSite:()=>X(t),pickTheme:o=>{t.theme.value=o},skipTheme:a(()=>{t.step.value="onboarding-done"}),saveTheme:()=>Z(t),backToLogin:a(()=>{t.step.value="login"}),forgot:a(()=>{const o=String(e.root.querySelector('#login-form-container input[type="email"]')?.value||"").trim();t.step.value="forgot",C(e,o)}),toggleRecovery:a(()=>x(t,t.chMode.value==="code"?"recovery":"code")),copySecret:async()=>{try{await navigator.clipboard.writeText(String(t.chSecret.value).replace(/\s+/g,"")),E.toast("Key copied.",{type:"success"})}catch{E.toast("Could not copy - select the key and copy it.",{type:"warning"})}},verify:o=>{o?.preventDefault?.(),_(e)},newLink:a(async()=>{try{w(t,await i.auth.setupStatus())}catch{}t.step.value="forgot",C(e)}),signInAfterReset:a(()=>{t.step.value="login",T(e,{email:e.email})})}}function Q(e){F.render("#setup-form-container",W,{},{layout:"stacked",submitText:"Create admin account",onSubmit:async t=>{try{const a=await i.auth.setup(t);f(a),e.state.step.value="onboarding-site"}catch(a){return E.toast(a.message||"Setup failed. Please try again.",{type:"error"}),!1}}}),g(e.root.querySelector("#setup-form-container"))}async function X(e){e.obSiteError.value="";const t=e.obTitle.value.trim(),a=e.obTagline.value.trim();e.obSiteSaving.value=!0;try{const o=await i.settings.get();await i.settings.save({...o,title:t||o.title,tagline:a||o.tagline,brand:{...o.brand||{},text:t||o.brand?.text||o.title},seo:{...o.seo||{},defaultTitle:t||o.seo&&o.seo.defaultTitle}}),e.step.value="onboarding-theme"}catch(o){e.obSiteError.value=o.message||"Could not save site details."}finally{e.obSiteSaving.value=!1}}async function Z(e){e.obThemeError.value="",e.obThemeSaving.value=!0;try{await i.theme.save({theme:e.theme.value||"charcoal-dark"}),e.step.value="onboarding-done"}catch(t){e.obThemeError.value=t.message||"Could not save theme."}finally{e.obThemeSaving.value=!1}}function T(e,{email:t=""}={}){F.render("#login-form-container",Y,t?{email:t}:{},{layout:"stacked",submitText:"Sign in",onSubmit:async a=>{try{const o=await i.auth.login(a);if(o?.challenge&&o.ticket)return k(e,o),!1;f(o),y(e.next)}catch(o){return E.toast(o.message||"Invalid credentials. Please try again.",{type:"error"}),!1}}}),g(e.root.querySelector("#login-form-container"))}function k(e,{challenge:t,ticket:a}){const{state:o}=e;e.ticket=a,o.chChallenge.value=t||{},o.chTrust.value=!1,x(o,"code"),o.step.value="challenge"}function x(e,t){e.chMode.value=t,e.chCode.value="",e.chError.value="",setTimeout(()=>document.getElementById("challenge-code")?.focus(),0)}async function _(e){const{state:t}=e;t.chBusy.value=!0;try{const a=await i.auth.verifyLogin({ticket:e.ticket,response:String(t.chCode.value||"").trim(),mode:t.chMode.value,trust:t.chTrust.value===!0});if(a?.challenge&&a.ticket){k(e,a);return}f(a),y(e.next)}catch(a){const o=a.message||"That did not match.";t.chError.value=o,/sign in again/i.test(o)&&setTimeout(()=>{t.step.value="login"},1800)}finally{t.chBusy.value=!1}}function C(e,t=""){const{state:a}=e;a.byEmail.value&&F.render("#forgot-form-container",K,t?{email:t}:{},{layout:"stacked",submitText:"Send reset link",onSubmit:async o=>{try{const s=await i.auth.forgotPassword(o.email);s?.expiresIn&&(a.expiresIn.value=s.expiresIn)}catch{}a.step.value="forgot-success"}})}async function $(e,t){const{state:a}=e;if(!t){a.step.value="reset-invalid";return}const o=E.loader(e.root,{type:"dots"});let s;i.auth.setupStatus().then(n=>w(a,n)).catch(()=>{});try{s=await i.auth.checkReset(t)}catch{a.step.value="reset-invalid";return}finally{o.destroy()}e.token=t,e.email=s.email||"",a.resetEmail.value=e.email,a.resetExpiry.value=O(s.minutesLeft),a.step.value="reset",ee(e)}function ee(e){const{state:t,token:a,email:o,pw:s}=e,n=e.root.querySelector("#reset-form-container"),c=()=>({password:String(n.querySelector('input[name="password"]')?.value||""),confirm:String(n.querySelector('input[name="confirmPassword"]')?.value||"")}),l=()=>{const{password:r,confirm:m}=c(),u=j({password:r,confirm:m,problems:s.problems}),q=[...u.messages,...u.mismatch?["The two passwords do not match yet."]:[]];return t.resetLines.value=r?q.map((L,P)=>({id:P,text:L})):[],u},p=async()=>{const{password:r}=c(),m=++s.asked;if(r.length<8){s.problems=[],l();return}try{const u=await i.auth.checkReset(a,r);m===s.asked&&(s.problems=u.problems||[])}catch(u){if(u.body?.expired){t.step.value="reset-invalid";return}}m===s.asked&&l()};F.render("#reset-form-container",U,{},{layout:"stacked",submitText:"Set new password",onSubmit:async r=>{await p();const m=l();if(!m.ok)return E.toast(m.mismatch?"The passwords do not match.":"That password does not meet the rules below.",{type:"error"}),!1;try{await i.auth.resetPassword(a,r.password),history.replaceState(null,"","#/reset-password"),t.step.value="reset-done"}catch(u){return u.body?.expired?(t.step.value="reset-invalid",!1):(s.problems=u.body?.problems||[],l(),E.toast(u.message||"The password was not changed.",{type:"error"}),!1)}}});const h=n.querySelector("form")||n;if(o){const r=document.createElement("input");r.type="email",r.name="username",r.autocomplete="username",r.value=o,r.readOnly=!0,r.hidden=!0,h.prepend(r)}g(n);const v=r=>{r.target.matches?.('input[type="password"], input[name="password"], input[name="confirmPassword"]')&&(r.target.name==="password"&&(s.problems=[]),l(),clearTimeout(s.timer),r.target.name==="password"&&(s.timer=setTimeout(p,400)))};n.addEventListener("input",v),d.add(()=>n.removeEventListener("input",v))}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
|
|
3
|
-
"source": "dcms-marketplace@
|
|
3
|
+
"source": "dcms-marketplace@6301533",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.8.0",
|
|
@@ -82,27 +82,28 @@
|
|
|
82
82
|
}
|
|
83
83
|
},
|
|
84
84
|
"security": {
|
|
85
|
-
"version": "1.0
|
|
85
|
+
"version": "1.1.0",
|
|
86
86
|
"files": {
|
|
87
|
-
"CLAUDE.md": "
|
|
87
|
+
"CLAUDE.md": "dc6f8755b8e6cdcac69cd928654395cde794e5b698032c132ce83dede24acead",
|
|
88
88
|
"admin/lib/blocks.js": "5ef346b7de751518f8beb9212e3b89b270ae236560ae3d8a1f40d129c50918ff",
|
|
89
89
|
"admin/lib/health.js": "3b233a3d29b2d69c333321602c13233b8221cfe1114aa03903739d9ae8ad65d5",
|
|
90
90
|
"admin/lib/ip.js": "cf33be4fa2e2cad37bafd12ebec17f293891a88de402caad1bf9c9a57ce87680",
|
|
91
91
|
"admin/lib/lockout.js": "c5e5e4b2934296f81ae8ed013725485bc269a0ef3cf3bdf3c16c53936635a1ee",
|
|
92
92
|
"admin/lib/policy.js": "a4aedfd28f992e0e7759bbdac6344681eebe617c27849e91fa4405f6190f8462",
|
|
93
93
|
"admin/lib/probes.js": "d56318903cb645a39f56e7762eddb7411941a72e1f446c1f9d79e35157f1d31b",
|
|
94
|
-
"admin/lib/
|
|
95
|
-
"admin/
|
|
96
|
-
"admin/
|
|
94
|
+
"admin/lib/resets.js": "3724a2aec9f3c18ada96948aa6ce375ae3505bb7623a2c556e2e2670c5ca343c",
|
|
95
|
+
"admin/lib/settings.js": "b319953914f3a6f029ba991b36fe6fcd521956ea099578be42505f30e6c32faf",
|
|
96
|
+
"admin/templates/security.html": "df5798497ca1fd5ed200ce0ff921a055168ac7720f38b2db1bebf2c0efe71f04",
|
|
97
|
+
"admin/views/security.js": "280c1681f1aae6cf82a141e670d046c160c5f5fc3cb9de43f9d134a08b15a199",
|
|
97
98
|
"bin/unlock.js": "5fcc3c0ef3ba3c424a366aa62896f51abad578183dfde283aa838a86549ca785",
|
|
98
99
|
"config.js": "e6a00f7816637687d6a68ebbac65a71489246a926c28523c2a78d859e75b7ae0",
|
|
99
|
-
"plugin.js": "
|
|
100
|
-
"plugin.json": "
|
|
100
|
+
"plugin.js": "db5dc669a6a26bbe8756da59bdd04217e403be48012921b6d2b868cec57af489",
|
|
101
|
+
"plugin.json": "319d4333d03ed7d42ef9c2d0999befff7594343be963b6fee9c90bf7916ad142",
|
|
101
102
|
"server/common-passwords.NOTICE": "3b148a49a569bfa24feaa7879db602da8f0607fa61ab6c52b7a62391fc7c009a",
|
|
102
103
|
"server/common-passwords.txt": "68782d6a4a19a4768d5f15dd66bd534e7a33055cc755411e33f16d18c50fdcce",
|
|
103
104
|
"server/facts.js": "25538a8d1cfe012688fceb8ec0d0dd7b43ac9e168430723d6fb731183701fb46",
|
|
104
105
|
"server/state.js": "1319b322e94a4aec176948990b2d674b28dfece761eef4d5685df474037bfc4a",
|
|
105
|
-
"tests/security.test.js": "
|
|
106
|
+
"tests/security.test.js": "f0cb70dcb28bc8b3de8d3b39064616296382c5b0f97bfd615ee8e24fe3b031bc"
|
|
106
107
|
}
|
|
107
108
|
},
|
|
108
109
|
"shopping-cart": {
|
|
@@ -15,6 +15,13 @@ Ships with the engine (free tier, `make sync-free-tier`); switched ON when it fi
|
|
|
15
15
|
MIT - see the NOTICE), not containing the name/email, not one or two characters repeated.
|
|
16
16
|
- **Sign-in log**: `data/signins.jsonl`, kept `signinDays`; notifications (sources `security:lockouts`,
|
|
17
17
|
`security:suspicious`, `security:probes` - the last off by default).
|
|
18
|
+
- **Reset requests** (1.1.0; core 0.88's `auth:resetRequested`): each "Forgot your password?" is a `kind: 'reset'`
|
|
19
|
+
line in the same log (`ok` = a link was sent, `reason` = the core's outcome - `admin/lib/resets.js` RESET_REASONS).
|
|
20
|
+
Sign-ins › Resets lists them; the overview tile counts 24 h. `security:resets` (setting `notify.resetProbing`, on) is
|
|
21
|
+
raised when one address asks about 3 different accounts within an hour - ALL the core allows it (forgot-password is
|
|
22
|
+
rate-limited to 3 an hour per address, so a higher threshold never trips) - or when 10 requests within an hour, from
|
|
23
|
+
any addresses, name accounts that do not exist (someone rotating addresses). Anything counting sign-ins must skip
|
|
24
|
+
`kind: 'reset'` lines - their `ok` is not a sign-in. Older engines never emit the event: nothing changes there.
|
|
18
25
|
- **Health check**: `server/facts.js` gathers (users, engine vs npm latest, certificate via tls, security headers of the
|
|
19
26
|
public home page, private paths fetched LOCALLY, TRUST_PROXY, bind, SMTP, licences); `admin/lib/health.js` judges.
|
|
20
27
|
Unknown = 'info', never a pass. Cached 10 min; the sidebar badge (`/count`) = checks needing attention, red only
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - password reset requests (core's auth:resetRequested, 0.88). Pure.
|
|
3
|
+
*
|
|
4
|
+
* Each "Forgot your password?" goes into the sign-in log as `kind: 'reset'`.
|
|
5
|
+
* One address asking for resets of many different accounts is someone trying
|
|
6
|
+
* addresses, or trying to fill inboxes - that is what `recordReset` watches.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** What became of a request, as the core reports it in `reason`. */
|
|
10
|
+
export const RESET_REASONS = {
|
|
11
|
+
sent: 'Link sent',
|
|
12
|
+
unknown: 'No such account',
|
|
13
|
+
inactive: 'Account switched off',
|
|
14
|
+
throttled: 'Held back - 3 an hour already',
|
|
15
|
+
'no-smtp': 'Not sent - no mail server',
|
|
16
|
+
'no-origin': 'Not sent - no Site URL',
|
|
17
|
+
failed: 'Email failed'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* When it is worth telling someone. The core lets one address ask 3 times an hour
|
|
22
|
+
* (a route rate limit), and a person only ever asks about their own account - so
|
|
23
|
+
* one address using all 3 on different accounts is trying addresses. Someone
|
|
24
|
+
* rotating addresses gets past that, which is what the site-wide count of
|
|
25
|
+
* requests for accounts that do not exist is for.
|
|
26
|
+
*/
|
|
27
|
+
export const RESET_PROBE = {threshold: 3, windowMin: 60};
|
|
28
|
+
export const RESET_UNKNOWN = {threshold: 10, windowMin: 60};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Note a reset request from an address and say whether it has just crossed the
|
|
32
|
+
* line: `threshold` different accounts within `windowMin`. Mutates `recent`
|
|
33
|
+
* (a Map of address -> [{at, email}]), dropping what is older than the window.
|
|
34
|
+
*
|
|
35
|
+
* @returns {{accounts: number, tripped: boolean}} tripped only on the request that reaches the threshold
|
|
36
|
+
*/
|
|
37
|
+
export function recordReset(recent, ip, email, {now = Date.now(), threshold = RESET_PROBE.threshold, windowMin = RESET_PROBE.windowMin} = {}) {
|
|
38
|
+
if (!ip) return {accounts: 0, tripped: false};
|
|
39
|
+
const since = now - windowMin * 60_000;
|
|
40
|
+
const list = (recent.get(ip) || []).filter(r => r.at >= since);
|
|
41
|
+
const before = new Set(list.map(r => r.email)).size;
|
|
42
|
+
list.push({at: now, email: String(email || '').trim().toLowerCase()});
|
|
43
|
+
recent.set(ip, list);
|
|
44
|
+
const accounts = new Set(list.map(r => r.email)).size;
|
|
45
|
+
return {accounts, tripped: accounts >= threshold && before < threshold};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Note a request for an account that does not exist, from any address, and say
|
|
50
|
+
* whether the site has just reached `threshold` of them within `windowMin`.
|
|
51
|
+
* Mutates `stamps` (an array of times).
|
|
52
|
+
*
|
|
53
|
+
* @returns {{count: number, tripped: boolean}} tripped only on the request that reaches the threshold
|
|
54
|
+
*/
|
|
55
|
+
export function recordUnknown(stamps, {now = Date.now(), threshold = RESET_UNKNOWN.threshold, windowMin = RESET_UNKNOWN.windowMin} = {}) {
|
|
56
|
+
const since = now - windowMin * 60_000;
|
|
57
|
+
const keep = stamps.filter(t => t >= since);
|
|
58
|
+
stamps.length = 0;
|
|
59
|
+
stamps.push(...keep, now);
|
|
60
|
+
return {count: stamps.length, tripped: stamps.length === threshold};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Forget site-wide stamps older than the window. Mutates `stamps`. */
|
|
64
|
+
export function pruneUnknown(stamps, {now = Date.now(), windowMin = RESET_UNKNOWN.windowMin} = {}) {
|
|
65
|
+
const keep = stamps.filter(t => t >= now - windowMin * 60_000);
|
|
66
|
+
stamps.length = 0;
|
|
67
|
+
stamps.push(...keep);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Forget addresses with nothing inside the window. */
|
|
71
|
+
export function pruneResets(recent, {now = Date.now(), windowMin = RESET_PROBE.windowMin} = {}) {
|
|
72
|
+
const since = now - windowMin * 60_000;
|
|
73
|
+
for (const [ip, list] of recent) {
|
|
74
|
+
const keep = list.filter(r => r.at >= since);
|
|
75
|
+
if (keep.length) recent.set(ip, keep); else recent.delete(ip);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One log entry as the Sign-ins list shows it. */
|
|
80
|
+
export function resetRowText(e) {
|
|
81
|
+
return {okText: 'Reset asked', tone: 'reset', note: RESET_REASONS[e.reason] || e.reason || (e.ok ? 'Link sent' : 'Not sent')};
|
|
82
|
+
}
|
|
@@ -11,7 +11,7 @@ export const DEFAULT_SETTINGS = {
|
|
|
11
11
|
probes: {enabled: true, blockAfter: 5, withinMin: 10, blockForMin: 60, extra: [], allow: []},
|
|
12
12
|
signinDays: 30,
|
|
13
13
|
staleAdminDays: 90,
|
|
14
|
-
notify: {lockouts: true, successAfterFails: true, probeBlocks: false}
|
|
14
|
+
notify: {lockouts: true, successAfterFails: true, probeBlocks: false, resetProbing: true}
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
const int = (v, min, max) => {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
<div class="scx-card scx-tile"><small>Failed sign-ins, 24h <span data-help="Failed sign-ins in the last 24 hours: a wrong password, an unknown email, a locked account."></span></small><strong data-bind-text="ov.value.failed">—</strong></div>
|
|
23
23
|
<div class="scx-card scx-tile"><small>Blocked now <span data-help="Addresses blocked by a rule or for probing, and accounts or addresses locked after wrong passwords."></span></small><strong data-bind-text="ov.value.blocked">—</strong></div>
|
|
24
24
|
<div class="scx-card scx-tile"><small>Probes refused today <span data-help="Requests for things no Domma site has - WordPress logins, .env files, database dumps. Scanners send them to every site they find; they are answered with an empty 'not found'."></span></small><strong data-bind-text="ov.value.probes">—</strong></div>
|
|
25
|
+
<div class="scx-card scx-tile"><small>Reset requests, 24h <span data-help=""Forgot your password?" requests in the last 24 hours, sent or not. Sign-ins › Resets lists them, with what happened and where from."></span></small><strong data-bind-text="ov.value.resets">—</strong></div>
|
|
25
26
|
</div>
|
|
26
27
|
|
|
27
28
|
<div class="scx-card">
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
<button type="button" data-on-click="res_all" data-bind-class="result.value === 'all' && 'is-on'">All</button>
|
|
42
43
|
<button type="button" data-on-click="res_failed" data-bind-class="result.value === 'failed' && 'is-on'">Failed</button>
|
|
43
44
|
<button type="button" data-on-click="res_ok" data-bind-class="result.value === 'ok' && 'is-on'">Succeeded</button>
|
|
45
|
+
<button type="button" data-on-click="res_resets" data-bind-class="result.value === 'resets' && 'is-on'">Resets</button>
|
|
44
46
|
</div>
|
|
45
47
|
<select class="form-input scx-days" aria-label="Period" data-model="days.value">
|
|
46
48
|
<option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="365">All kept</option>
|
|
@@ -150,6 +152,7 @@
|
|
|
150
152
|
.scx-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
151
153
|
.scx-pill { display: inline-flex; align-items: center; justify-self: start; font-size: var(--dm-text-xs); font-weight: 600; padding: .05rem .5rem; border-radius: 999px; }
|
|
152
154
|
.scx-pill[data-ok="true"] { color: var(--dm-success); background: color-mix(in srgb, var(--dm-success) 14%, transparent); }
|
|
155
|
+
.scx-pill[data-ok="reset"] { color: var(--dm-info); background: color-mix(in srgb, var(--dm-info) 14%, transparent); }
|
|
153
156
|
.scx-pill[data-ok="false"] { color: var(--dm-danger); background: color-mix(in srgb, var(--dm-danger) 12%, transparent); }
|
|
154
157
|
.scx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); line-height: 1; color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .25rem; }
|
|
155
158
|
.scx-kebab:hover { color: var(--dm-primary); }
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* only, and rows are addressed by index.
|
|
11
11
|
* `#/plugins/security/<tab>` opens a tab. Pure logic: ../lib/*.js.
|
|
12
12
|
*/
|
|
13
|
+
import {resetRowText} from '../lib/resets.js';
|
|
13
14
|
import {checkSettings, DEFAULT_SETTINGS, mergeDefaults} from '../lib/settings.js';
|
|
14
15
|
import {ipRuleValid} from '../lib/ip.js';
|
|
15
16
|
|
|
@@ -152,7 +153,8 @@ export const securityView = {
|
|
|
152
153
|
const asOf = M.computed(() => (health.value?.at ? `Checked ${new Date(health.value.at).toLocaleTimeString('en-GB', {hour: '2-digit', minute: '2-digit'})}` : ''));
|
|
153
154
|
const ov = M.computed(() => {
|
|
154
155
|
const o = overview.value;
|
|
155
|
-
return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0)
|
|
156
|
+
return o ? {failed: String(o.failed24h), blocked: String(o.blocked + o.locks), probes: String(o.today?.probes || 0), resets: String(o.resets24h ?? 0)}
|
|
157
|
+
: {failed: '—', blocked: '—', probes: '—', resets: '—'};
|
|
156
158
|
});
|
|
157
159
|
const blockedCount = M.computed(() => { const b = blocks.value; const n = b.rules.length + b.temp.length + b.locks.length; return n ? String(n) : ''; });
|
|
158
160
|
|
|
@@ -185,18 +187,25 @@ export const securityView = {
|
|
|
185
187
|
const q = query.value.trim().toLowerCase();
|
|
186
188
|
const r = result.value;
|
|
187
189
|
const only = ipFilter.value;
|
|
188
|
-
|
|
190
|
+
// A reset request is in the log too (kind 'reset'): only under All and Resets.
|
|
191
|
+
const isReset = (e) => e.kind === 'reset';
|
|
192
|
+
signinList = signins.value.filter(e => (r === 'all' || (r === 'resets' ? isReset(e) : !isReset(e) && (r === 'ok') === Boolean(e.ok)))
|
|
189
193
|
&& (!only || e.ip === only)
|
|
190
194
|
&& (!q || [e.email, e.ip, e.ua, e.reason].some(v => String(v || '').toLowerCase().includes(q))));
|
|
191
|
-
signinRows.value = signinList.slice(0, 1000).map((e, i) =>
|
|
192
|
-
id: `s${i}`, when: when(e.at),
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
signinRows.value = signinList.slice(0, 1000).map((e, i) => {
|
|
196
|
+
const row = {id: `s${i}`, when: when(e.at), email: e.email || '—', ip: e.ip || '—', ua: e.ua || ''};
|
|
197
|
+
if (isReset(e)) {
|
|
198
|
+
const t = resetRowText(e);
|
|
199
|
+
return {...row, ok: t.tone, okText: t.okText, note: `${t.note} · ${browserOf(e.ua)}`};
|
|
200
|
+
}
|
|
201
|
+
return {...row, ok: String(Boolean(e.ok)), okText: e.ok ? 'Signed in' : 'Failed',
|
|
202
|
+
note: e.ok ? browserOf(e.ua) : `${REASONS[e.reason] || e.reason || 'Failed'} · ${browserOf(e.ua)}`};
|
|
203
|
+
});
|
|
196
204
|
}));
|
|
197
205
|
const signinText = M.computed(() => {
|
|
198
206
|
const n = signinList.length + 0 * signinRows.value.length;
|
|
199
207
|
const f = ipFilter.value ? ` from ${ipFilter.value}` : '';
|
|
208
|
+
if (result.value === 'resets') return `${n.toLocaleString('en-GB')} reset request${n === 1 ? '' : 's'}${f}`;
|
|
200
209
|
return `${n.toLocaleString('en-GB')} sign-in${n === 1 ? '' : 's'}${f}`;
|
|
201
210
|
});
|
|
202
211
|
const signinEmpty = M.computed(() => (query.value.trim() || ipFilter.value ? 'No sign-ins match.' : 'No sign-ins in this period.'));
|
|
@@ -280,7 +289,9 @@ export const securityView = {
|
|
|
280
289
|
}
|
|
281
290
|
function exportCsv() {
|
|
282
291
|
if (!signinList.length) { E.toast('Nothing to export.', {type: 'info'}); return; }
|
|
283
|
-
const rows = signinList.map(e => (
|
|
292
|
+
const rows = signinList.map(e => (e.kind === 'reset'
|
|
293
|
+
? {time: e.at, result: 'reset requested', email: e.email, address: e.ip, reason: resetRowText(e).note, browser: e.ua}
|
|
294
|
+
: {time: e.at, result: e.ok ? 'signed in' : 'failed', email: e.email, address: e.ip, reason: e.ok ? '' : (REASONS[e.reason] || e.reason || ''), browser: e.ua}));
|
|
284
295
|
const url = URL.createObjectURL(new Blob([toCsv(rows)], {type: 'text/csv;charset=utf-8'}));
|
|
285
296
|
const a = Object.assign(document.createElement('a'), {href: url, download: `sign-ins-${location.hostname}-${new Date().toISOString().slice(0, 10)}.csv`});
|
|
286
297
|
document.body.appendChild(a); a.click(); a.remove();
|
|
@@ -385,14 +396,14 @@ export const securityView = {
|
|
|
385
396
|
probeOn: M.observable(s.probes.enabled), after: M.observable(String(s.probes.blockAfter)), within: M.observable(String(s.probes.withinMin)), forMin: M.observable(String(s.probes.blockForMin)),
|
|
386
397
|
extra: M.observable(s.probes.extra.join('\n')), allow: M.observable(s.probes.allow.join('\n')),
|
|
387
398
|
signinDays: M.observable(String(s.signinDays)), staleDays: M.observable(String(s.staleAdminDays)),
|
|
388
|
-
nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks)
|
|
399
|
+
nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks), nReset: M.observable(s.notify.resetProbing)
|
|
389
400
|
};
|
|
390
401
|
const body = () => ({
|
|
391
402
|
lockout: {enabled: f.lockOn.value, accountThreshold: f.acc.value, addressThreshold: f.addr.value, windowMin: f.win.value, baseMin: f.base.value, maxMin: f.max.value},
|
|
392
403
|
policy: {enabled: f.polOn.value, minLength: f.minLen.value, blockCommon: f.common.value, blockPersonal: f.personal.value},
|
|
393
404
|
probes: {enabled: f.probeOn.value, blockAfter: f.after.value, withinMin: f.within.value, blockForMin: f.forMin.value, extra: f.extra.value, allow: f.allow.value},
|
|
394
405
|
signinDays: f.signinDays.value, staleAdminDays: f.staleDays.value,
|
|
395
|
-
notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value}
|
|
406
|
+
notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value, resetProbing: f.nReset.value}
|
|
396
407
|
});
|
|
397
408
|
const opened = JSON.stringify(body());
|
|
398
409
|
const saving = M.observable(false);
|
|
@@ -436,6 +447,7 @@ export const securityView = {
|
|
|
436
447
|
${sw('nLock', 'Notify when an account or address is locked')}
|
|
437
448
|
${sw('nAfter', 'Notify when someone signs in after many wrong passwords')}
|
|
438
449
|
${sw('nProbe', 'Notify when a scanner is blocked', 'Off by default: on a public site this happens daily.')}
|
|
450
|
+
${sw('nReset', 'Notify when password resets look like someone trying addresses', 'One address asking about 3 different accounts within an hour (a person only asks about their own), or 10 requests in an hour for accounts that do not exist, from anywhere. Every request is in the sign-in log either way.')}
|
|
439
451
|
</div></section>
|
|
440
452
|
<section><h4>Locked out yourself?</h4>
|
|
441
453
|
<p class="scx-note">Run this in the site's folder on the server - it clears lockouts and blocks, and the site notices within seconds:</p>
|
|
@@ -499,6 +511,7 @@ export const securityView = {
|
|
|
499
511
|
const model = {tab, loading, error, checksOk, canManage, scoreText, scoreWidth, scoreTone, attentionText, asOf, ov, blockedCount, checks,
|
|
500
512
|
result, days, query, signinRows, signinText, signinEmpty, lockRows, tempRows, ruleRows, newRule, newNote, youText};
|
|
501
513
|
const methods = {
|
|
514
|
+
res_resets: () => { result.value = 'resets'; },
|
|
502
515
|
tab_overview: () => setTab('overview'), tab_signins: () => setTab('signins'), tab_blocked: () => setTab('blocked'),
|
|
503
516
|
res_all: () => { result.value = 'all'; }, res_failed: () => { result.value = 'failed'; }, res_ok: () => { result.value = 'ok'; },
|
|
504
517
|
exportCsv,
|
|
@@ -31,6 +31,7 @@ import {checkSettings, mergeDefaults} from './admin/lib/settings.js';
|
|
|
31
31
|
import {accountKey, activeLocks, addressKey, lockedUntil, pruneLockouts, recordFailure, recordSuccess, retryText} from './admin/lib/lockout.js';
|
|
32
32
|
import {passwordProblems} from './admin/lib/policy.js';
|
|
33
33
|
import {isProbe} from './admin/lib/probes.js';
|
|
34
|
+
import {pruneResets, pruneUnknown, recordReset, recordUnknown} from './admin/lib/resets.js';
|
|
34
35
|
import {blockedBy, isTrusted, pruneBlocks, recordProbe} from './admin/lib/blocks.js';
|
|
35
36
|
import {ipRuleValid, isLoopbackIp, plainIp} from './admin/lib/ip.js';
|
|
36
37
|
import {getConfig, saveConfig} from '../../server/config.js';
|
|
@@ -72,6 +73,7 @@ async function security(fastify, options = {}) {
|
|
|
72
73
|
const notify = (p) => { try { hooks.notify?.(p); } catch { /* notifications are a courtesy */ } };
|
|
73
74
|
hooks.registerNotificationSource?.({id: 'lockouts', label: 'Sign-in lockouts', description: 'An account or address was locked after repeated wrong passwords.', severity: 'warning', audience: 'admins'});
|
|
74
75
|
hooks.registerNotificationSource?.({id: 'suspicious', label: 'Suspicious sign-ins', description: 'Someone signed in after many wrong passwords.', severity: 'warning', audience: 'admins'});
|
|
76
|
+
hooks.registerNotificationSource?.({id: 'resets', label: 'Password reset probing', description: 'One address asked for password resets of many different accounts.', severity: 'warning', audience: 'admins'});
|
|
75
77
|
hooks.registerNotificationSource?.({id: 'probes', label: 'Scanners blocked', description: 'An address probing for weaknesses was blocked.', severity: 'info', audience: 'admins', defaultOn: false});
|
|
76
78
|
|
|
77
79
|
// ---------------------------------------------------------------------
|
|
@@ -161,6 +163,38 @@ async function security(fastify, options = {}) {
|
|
|
161
163
|
}
|
|
162
164
|
});
|
|
163
165
|
|
|
166
|
+
// Password reset requests (core 0.88's auth:resetRequested): into the
|
|
167
|
+
// sign-in log, and a notice when one address asks about many accounts.
|
|
168
|
+
const recentResets = new Map();
|
|
169
|
+
const unknownResets = [];
|
|
170
|
+
hooks.on?.('auth:resetRequested', (e) => {
|
|
171
|
+
try {
|
|
172
|
+
const ip = plainIp(e.ip);
|
|
173
|
+
state.logSignin({at: new Date().toISOString(), kind: 'reset', ok: Boolean(e.sent), email: e.email || '', userId: e.userId || null,
|
|
174
|
+
ip, ua: e.ua || '', reason: e.reason || ''});
|
|
175
|
+
if (isLoopbackIp(ip)) return;
|
|
176
|
+
if (e.reason === 'unknown') {
|
|
177
|
+
const many = recordUnknown(unknownResets);
|
|
178
|
+
if (many.tripped && cfg.notify.resetProbing !== false) {
|
|
179
|
+
notify({source: 'resets', severity: 'warning', title: `${many.count} password resets for accounts that do not exist`,
|
|
180
|
+
dedupeKey: 'reset-unknown',
|
|
181
|
+
body: `Within the hour, ${many.count} "Forgot your password?" requests named accounts this site does not have, from ${new Set(state.readSignins(Date.now() - 3_600_000).filter(x => x.kind === 'reset' && x.reason === 'unknown').map(x => x.ip)).size || 'several'} addresses - someone trying email addresses. The sign-in log (Resets) shows which.`,
|
|
182
|
+
link: '#/plugins/security/signins'});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!ip) return;
|
|
186
|
+
const {accounts, tripped} = recordReset(recentResets, ip, e.email);
|
|
187
|
+
if (tripped && cfg.notify.resetProbing !== false) {
|
|
188
|
+
notify({source: 'resets', severity: 'warning', title: `Password resets asked for ${accounts} accounts from ${ip}`,
|
|
189
|
+
dedupeKey: `reset-probe:${ip}`,
|
|
190
|
+
body: `${ip} asked for password resets of ${accounts} different accounts within the hour - a person only ever asks about their own, so this is someone trying email addresses. The log shows which; block the address if it is not someone you know.`,
|
|
191
|
+
link: '#/plugins/security/signins'});
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
log.warn(`[security] reset request not recorded: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
164
198
|
hooks.on?.('auth:loginSucceeded', (e) => {
|
|
165
199
|
try {
|
|
166
200
|
const ip = plainIp(e.ip);
|
|
@@ -217,12 +251,14 @@ async function security(fastify, options = {}) {
|
|
|
217
251
|
routes.get('/overview', may('read'), async () => {
|
|
218
252
|
const s = state.get();
|
|
219
253
|
const now = Date.now();
|
|
220
|
-
const
|
|
254
|
+
const all = state.readSignins(now - 86_400_000);
|
|
255
|
+
const day = all.filter(e => e.kind !== 'reset');
|
|
221
256
|
const today = s.counters.day === new Date().toISOString().slice(0, 10) ? s.counters : {probes: 0, blocks: 0, failed: 0};
|
|
222
257
|
return {
|
|
223
258
|
today,
|
|
224
259
|
failed24h: day.filter(e => !e.ok).length,
|
|
225
260
|
succeeded24h: day.filter(e => e.ok).length,
|
|
261
|
+
resets24h: all.length - day.length,
|
|
226
262
|
locks: activeLocks(s.lockouts, now).length,
|
|
227
263
|
blocked: Object.values(s.temp).filter(t => t.until > now).length + s.rules.length,
|
|
228
264
|
checks: canCheck,
|
|
@@ -302,6 +338,8 @@ async function security(fastify, options = {}) {
|
|
|
302
338
|
// ---------------------------------------------------------------------
|
|
303
339
|
const tidy = () => {
|
|
304
340
|
state.change((s) => { pruneLockouts(s.lockouts, {windowMin: cfg.lockout.windowMin}); pruneBlocks(s); });
|
|
341
|
+
pruneResets(recentResets);
|
|
342
|
+
pruneUnknown(unknownResets);
|
|
305
343
|
state.pruneSignins(cfg.signinDays);
|
|
306
344
|
};
|
|
307
345
|
const daily = setInterval(tidy, 6 * 3_600_000);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "security",
|
|
3
3
|
"displayName": "Security",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Keeps a Domma CMS site safe: a health check of what is weak and how to fix it, sign-in lockout after repeated wrong passwords, password rules, a log of every sign-in, and blocking of the scanners that probe every site for WordPress, .env files and the like.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-09-
|
|
8
|
+
"date": "2026-09-27",
|
|
9
9
|
"icon": "shield",
|
|
10
10
|
"permissions": [
|
|
11
11
|
{
|
|
@@ -132,3 +132,37 @@ test('health: facts become checks; unknown is info, never a pass; the score coun
|
|
|
132
132
|
assert.equal(unknown.checks.find(c => c.id === 'certificate').status, 'info');
|
|
133
133
|
assert.equal(unknown.checks.find(c => c.id === 'headers').status, 'info');
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
test('resets: one address asking about 3 different accounts in an hour trips once', async () => {
|
|
137
|
+
const {recordReset, pruneResets, resetRowText} = await import('../admin/lib/resets.js');
|
|
138
|
+
const recent = new Map();
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const at = (m) => ({now: now + m * MIN});
|
|
141
|
+
assert.deepEqual(recordReset(recent, '1.2.3.4', 'a@x.test', at(0)), {accounts: 1, tripped: false});
|
|
142
|
+
// The same account again is not another account.
|
|
143
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'A@x.test', at(1)).accounts, 1);
|
|
144
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'b@x.test', at(2)).tripped, false);
|
|
145
|
+
assert.deepEqual(recordReset(recent, '1.2.3.4', 'c@x.test', at(3)), {accounts: 3, tripped: true});
|
|
146
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'd@x.test', at(4)).tripped, false, 'only on crossing');
|
|
147
|
+
// Another address is counted on its own; an hour later the first starts again.
|
|
148
|
+
assert.equal(recordReset(recent, '5.6.7.8', 'a@x.test', at(5)).accounts, 1);
|
|
149
|
+
assert.equal(recordReset(recent, '1.2.3.4', 'z@x.test', at(70)).accounts, 1);
|
|
150
|
+
pruneResets(recent, {now: now + 200 * MIN});
|
|
151
|
+
assert.equal(recent.size, 0);
|
|
152
|
+
assert.equal(recordReset(recent, '', 'a@x.test').tripped, false);
|
|
153
|
+
assert.equal(resetRowText({reason: 'no-smtp'}).note, 'Not sent - no mail server');
|
|
154
|
+
assert.equal(resetRowText({reason: 'odd'}).note, 'odd');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('resets: 10 requests for unknown accounts in an hour, from anywhere, trip once', async () => {
|
|
158
|
+
const {recordUnknown, pruneUnknown} = await import('../admin/lib/resets.js');
|
|
159
|
+
const stamps = [];
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
for (let i = 0; i < 9; i++) assert.equal(recordUnknown(stamps, {now: now + i * MIN}).tripped, false);
|
|
162
|
+
assert.deepEqual(recordUnknown(stamps, {now: now + 9 * MIN}), {count: 10, tripped: true});
|
|
163
|
+
assert.equal(recordUnknown(stamps, {now: now + 10 * MIN}).tripped, false, 'only on reaching it');
|
|
164
|
+
// An hour on, the early ones have gone.
|
|
165
|
+
assert.equal(recordUnknown(stamps, {now: now + 65 * MIN}).count, 7, 'minutes 5-10 still inside the hour, and this one');
|
|
166
|
+
pruneUnknown(stamps, {now: now + 200 * MIN});
|
|
167
|
+
assert.equal(stamps.length, 0);
|
|
168
|
+
});
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* POST /forms/submit/:slug - public: accept submission
|
|
20
20
|
* POST /forms/test-email - admin: send test email
|
|
21
21
|
*/
|
|
22
|
+
import {keepSubmissionsOut} from '../../services/siteGitignore.js';
|
|
22
23
|
import {createRequire} from 'module';
|
|
23
24
|
import {fileURLToPath} from 'url';
|
|
24
25
|
import path from 'path';
|
|
@@ -893,6 +894,8 @@ export async function formsRoutes(fastify) {
|
|
|
893
894
|
excused,
|
|
894
895
|
outcome: ended ? {type: 'ended', trigger: ended.key, field: ended.field} : null
|
|
895
896
|
});
|
|
897
|
+
// What a visitor sent is theirs, not the site's content: keep it out of the site repo.
|
|
898
|
+
try { keepSubmissionsOut(ROOT, targetSlug); } catch { /* never fail a submission over this */ }
|
|
896
899
|
} else {
|
|
897
900
|
// Could not resolve or provision a target - e.g. collection
|
|
898
901
|
// storage is explicitly disabled with no slug. Fail loudly
|
|
@@ -84,9 +84,114 @@ export const PRIVATE_DATA = [
|
|
|
84
84
|
'contacts', 'contacts-contacts', 'contacts-groups', 'roles', 'user-profiles',
|
|
85
85
|
'user-messages', 'user-message-threads', 'analytics-events', 'enquiries',
|
|
86
86
|
'blog-comments', 'invoices', 'invoice-receivers', 'invoice-issuers',
|
|
87
|
-
'surveys-contacts', 'surveys-invites'
|
|
87
|
+
'surveys-contacts', 'surveys-invites',
|
|
88
|
+
// Paid plugins' visitor and customer records (0.88.1): Cart Pro's orders and
|
|
89
|
+
// discount codes, Calendar's RSVPs, the job board's people and applications.
|
|
90
|
+
'shop-orders', 'shop-pro-codes', 'calendar-rsvps',
|
|
91
|
+
'jb-applications', 'jb-candidate-profiles', 'jb-signups'
|
|
88
92
|
];
|
|
89
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The collection a form stores its submissions in - the same choice as the
|
|
96
|
+
* submit route (routes/api/forms.js): the collection action's slug when it is
|
|
97
|
+
* on, otherwise the form's own slug. An action slug set but switched off is
|
|
98
|
+
* kept out too: ensureCollectionForForm provisions into it.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} form - Parsed content/forms/<slug>.json
|
|
101
|
+
* @returns {string[]} collection slugs
|
|
102
|
+
*/
|
|
103
|
+
export function formDataCollections(form) {
|
|
104
|
+
const action = form?.actions?.collection;
|
|
105
|
+
const safe = (v) => (typeof v === 'string' && /^[a-z0-9][a-z0-9_-]*$/i.test(v) ? v : '');
|
|
106
|
+
return [...new Set([safe(action?.slug), safe(form?.slug)].filter(Boolean))];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `[form name="x" /]` (or slug="x") anywhere in a text. */
|
|
110
|
+
function embeddedFormNames(text) {
|
|
111
|
+
const out = new Set();
|
|
112
|
+
for (const m of String(text).matchAll(/\[form\b([^\]]*)\]/gi)) {
|
|
113
|
+
const a = m[1].match(/\b(?:name|slug)\s*=\s*["']?([A-Za-z0-9_-]+)/);
|
|
114
|
+
if (a) out.add(a[1]);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readTree(dir, exts, out = [], depth = 0) {
|
|
120
|
+
if (depth > 6) return out;
|
|
121
|
+
let entries = [];
|
|
122
|
+
try { entries = fs.readdirSync(dir, {withFileTypes: true}); } catch { return out; }
|
|
123
|
+
for (const e of entries) {
|
|
124
|
+
const full = path.join(dir, e.name);
|
|
125
|
+
if (e.isDirectory()) { if (e.name !== 'media' && e.name !== 'collections') readTree(full, exts, out, depth + 1); }
|
|
126
|
+
else if (exts.some(x => e.name.endsWith(x))) out.push(full);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The collections this site's VISITORS write to, from what is on disk - like
|
|
133
|
+
* PRIVATE_DATA, but only the site knows which they are. Before 0.88.1 nothing
|
|
134
|
+
* did this: a form storing in `find-a-tutor` had every enquiry - names, emails,
|
|
135
|
+
* phone numbers - committed to the site repo.
|
|
136
|
+
*
|
|
137
|
+
* "A form writes there" is not enough: every collection created in the admin
|
|
138
|
+
* gets a form of its own (ensureFormForCollection), and a content collection
|
|
139
|
+
* (FAQs, testimonials) must stay tracked. So a collection counts when
|
|
140
|
+
* - a form storing in it is EMBEDDED somewhere in content ([form name=...]), or
|
|
141
|
+
* - it was made by a form (schema meta.generatedFromForm).
|
|
142
|
+
* The submit route adds the rest the moment a visitor's entry is stored
|
|
143
|
+
* (keepFormDataOut) - the one signal that is never wrong.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} siteRoot
|
|
146
|
+
* @returns {string[]} sorted, de-duplicated
|
|
147
|
+
*/
|
|
148
|
+
export function siteFormDataCollections(siteRoot) {
|
|
149
|
+
const content = path.join(siteRoot, 'content');
|
|
150
|
+
const embedded = new Set();
|
|
151
|
+
for (const f of readTree(content, ['.md', '.html', '.json'])) {
|
|
152
|
+
if (f.startsWith(path.join(content, 'forms') + path.sep)) continue;
|
|
153
|
+
try { for (const n of embeddedFormNames(fs.readFileSync(f, 'utf8'))) embedded.add(n); } catch { /* skip */ }
|
|
154
|
+
}
|
|
155
|
+
const out = new Set();
|
|
156
|
+
const formsDir = path.join(content, 'forms');
|
|
157
|
+
let files = [];
|
|
158
|
+
try { files = fs.readdirSync(formsDir).filter(f => f.endsWith('.json')); } catch { /* no forms */ }
|
|
159
|
+
for (const f of files) {
|
|
160
|
+
try {
|
|
161
|
+
const form = JSON.parse(fs.readFileSync(path.join(formsDir, f), 'utf8'));
|
|
162
|
+
if (embedded.has(form.slug) || embedded.has(f.replace(/\.json$/, ''))) for (const c of formDataCollections(form)) out.add(c);
|
|
163
|
+
} catch { /* an unreadable form keeps nothing out - and breaks nothing */ }
|
|
164
|
+
}
|
|
165
|
+
const colDir = path.join(content, 'collections');
|
|
166
|
+
let cols = [];
|
|
167
|
+
try { cols = fs.readdirSync(colDir); } catch { /* none */ }
|
|
168
|
+
for (const c of cols) {
|
|
169
|
+
try {
|
|
170
|
+
const schema = JSON.parse(fs.readFileSync(path.join(colDir, c, 'schema.json'), 'utf8'));
|
|
171
|
+
if (schema?.meta?.generatedFromForm) out.add(c);
|
|
172
|
+
} catch { /* no schema */ }
|
|
173
|
+
}
|
|
174
|
+
return [...out].sort();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The rule that keeps one collection's entries out. */
|
|
178
|
+
export const collectionDataRule = (slug) => `/content/collections/${slug}/data.json`;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Keep a collection a visitor has just written to out of a managed .gitignore -
|
|
182
|
+
* called by the submit route once an entry is stored, so submissions never wait
|
|
183
|
+
* for the next regeneration to stop being committed. Same guard as
|
|
184
|
+
* allowInGitignore: a missing or hand-written file is left alone.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} siteRoot
|
|
187
|
+
* @param {string} slug - The collection the entry went into
|
|
188
|
+
* @returns {string[]} The rules actually added
|
|
189
|
+
*/
|
|
190
|
+
export function keepSubmissionsOut(siteRoot, slug) {
|
|
191
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(String(slug || '')) || PRIVATE_DATA.includes(slug)) return [];
|
|
192
|
+
return allowInGitignore(siteRoot, [collectionDataRule(slug)]);
|
|
193
|
+
}
|
|
194
|
+
|
|
90
195
|
/**
|
|
91
196
|
* Lines bin/update.js appends when it cannot find them VERBATIM. They are
|
|
92
197
|
* already ignored by `/*` and `/plugins/*`, but writing them out keeps the
|
|
@@ -151,10 +256,11 @@ export function pluginContentRules(name, rel) {
|
|
|
151
256
|
* @param {Object<string,string[]>} [opts.pluginContent={}] - Extra content folders in distributed plugins
|
|
152
257
|
* @param {string[]} [opts.sitePublic=[]] - Site-added entries directly under public/
|
|
153
258
|
* @param {string[]} [opts.extras=[]] - Other site-owned top-level entries (docs, tools, README.md)
|
|
259
|
+
* @param {string[]} [opts.formData=[]] - Collections the site's forms store submissions in (siteFormDataCollections)
|
|
154
260
|
* @param {string[]} [opts.siteRules=[]] - The site-specific section
|
|
155
261
|
* @returns {string}
|
|
156
262
|
*/
|
|
157
|
-
export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic = [], extras = [], siteRules = []} = {}) {
|
|
263
|
+
export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic = [], extras = [], formData = [], siteRules = []} = {}) {
|
|
158
264
|
const L = [
|
|
159
265
|
MANAGED_HEADER,
|
|
160
266
|
'# This repo holds the site\'s CONTENT only. Everything is ignored, then what',
|
|
@@ -192,7 +298,12 @@ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic
|
|
|
192
298
|
L.push('', '# Schemas the CMS rewrites on every boot - engine-owned, not authored');
|
|
193
299
|
for (const c of ENGINE_SCHEMAS) L.push(`/content/collections/${c}/schema.json`);
|
|
194
300
|
L.push('', '# Secrets, visitor-submitted and per-user data, runtime state');
|
|
195
|
-
for (const c of PRIVATE_DATA) L.push(
|
|
301
|
+
for (const c of PRIVATE_DATA) L.push(collectionDataRule(c));
|
|
302
|
+
const submitted = [...new Set(formData)].filter(c => !PRIVATE_DATA.includes(c)).sort();
|
|
303
|
+
if (submitted.length) {
|
|
304
|
+
L.push('', '# Form submissions - what visitors sent (siteFormDataCollections)');
|
|
305
|
+
for (const c of submitted) L.push(collectionDataRule(c));
|
|
306
|
+
}
|
|
196
307
|
L.push(
|
|
197
308
|
'/content/users/',
|
|
198
309
|
'/content/sessions/',
|