From 4d2d9655c677c7668488a5ad12dd03d96dd8cf64 Mon Sep 17 00:00:00 2001 From: cmitchell8 Date: Thu, 14 May 2026 11:17:45 -0700 Subject: [PATCH] Watercolor FE redesign: tokens, screens, drawer, bookmark wiring Adds a new dojo.watercolor app providing the five PRD screens (Home, Browse, Queue, Reports, finding Drawer partial) under the additive /watercolor/ URL namespace. All visual tokens (palette, severity washes, six category gradients, radii, shadows, motion) are scoped under body.theme-watercolor so the redesign cannot leak into existing DefectDojo surfaces. Views re-use the queryset helpers (_user_findings, _open_findings) backing the existing dashboard ViewSets and the shared categorize_queryset helper so no aggregation logic is duplicated. Per-view scoping uses get_authorized_findings(Permissions.Finding_View). Bookmarks round-trip through the existing /api/v2/bookmarks/ endpoints; the drawer is fetched as an HTML partial from /watercolor/finding//drawer/. Includes 25 pre-baked SVG covers (5 per severity, picked by finding.id % 5) and 6 category icons. Fonts fall back to Georgia + system-ui per the PRD's font-licensing risk decision. Reports cards are a discoverability shell linking to the existing report builder. Tests cover anon redirects, authenticated 200s, the verbatim Queue empty copy, the bookmarked-OR-assigned Queue union, permission isolation across two analysts, drawer 200/404 contract, and Browse querystring filters. Co-Authored-By: Claude Opus 4.7 (1M context) --- dojo/settings/settings.dist.py | 1 + dojo/static/dojo/css/watercolor.css | 787 ++++++++++++++++++ .../dojo/img/watercolor/category/cloud.svg | 2 + .../dojo/img/watercolor/category/config.svg | 2 + .../dojo/img/watercolor/category/data.svg | 2 + .../dojo/img/watercolor/category/identity.svg | 2 + .../dojo/img/watercolor/category/supply.svg | 2 + .../dojo/img/watercolor/category/web.svg | 2 + .../dojo/img/watercolor/covers/critical-1.svg | 2 + .../dojo/img/watercolor/covers/critical-2.svg | 2 + .../dojo/img/watercolor/covers/critical-3.svg | 2 + .../dojo/img/watercolor/covers/critical-4.svg | 2 + .../dojo/img/watercolor/covers/critical-5.svg | 2 + .../dojo/img/watercolor/covers/high-1.svg | 2 + .../dojo/img/watercolor/covers/high-2.svg | 2 + .../dojo/img/watercolor/covers/high-3.svg | 2 + .../dojo/img/watercolor/covers/high-4.svg | 2 + .../dojo/img/watercolor/covers/high-5.svg | 2 + .../dojo/img/watercolor/covers/info-1.svg | 2 + .../dojo/img/watercolor/covers/info-2.svg | 2 + .../dojo/img/watercolor/covers/info-3.svg | 2 + .../dojo/img/watercolor/covers/info-4.svg | 2 + .../dojo/img/watercolor/covers/info-5.svg | 2 + .../dojo/img/watercolor/covers/low-1.svg | 2 + .../dojo/img/watercolor/covers/low-2.svg | 2 + .../dojo/img/watercolor/covers/low-3.svg | 2 + .../dojo/img/watercolor/covers/low-4.svg | 2 + .../dojo/img/watercolor/covers/low-5.svg | 2 + .../dojo/img/watercolor/covers/medium-1.svg | 2 + .../dojo/img/watercolor/covers/medium-2.svg | 2 + .../dojo/img/watercolor/covers/medium-3.svg | 2 + .../dojo/img/watercolor/covers/medium-4.svg | 2 + .../dojo/img/watercolor/covers/medium-5.svg | 2 + dojo/static/dojo/js/watercolor.js | 208 +++++ dojo/templates/dojo/watercolor/_base.html | 40 + .../dojo/watercolor/_partials/_card.html | 23 + .../watercolor/_partials/_category_tile.html | 11 + .../watercolor/_partials/_counter_cell.html | 5 + .../dojo/watercolor/_partials/_drawer.html | 69 ++ .../dojo/watercolor/_partials/_top_nav.html | 44 + dojo/templates/dojo/watercolor/browse.html | 86 ++ dojo/templates/dojo/watercolor/home.html | 75 ++ dojo/templates/dojo/watercolor/queue.html | 21 + dojo/templates/dojo/watercolor/reports.html | 24 + dojo/urls.py | 2 + dojo/watercolor/__init__.py | 2 + dojo/watercolor/apps.py | 13 + dojo/watercolor/templatetags/__init__.py | 1 + dojo/watercolor/templatetags/watercolor.py | 114 +++ dojo/watercolor/urls.py | 17 + dojo/watercolor/views.py | 352 ++++++++ unittests/test_watercolor_views.py | 222 +++++ 52 files changed, 2179 insertions(+) create mode 100644 dojo/static/dojo/css/watercolor.css create mode 100644 dojo/static/dojo/img/watercolor/category/cloud.svg create mode 100644 dojo/static/dojo/img/watercolor/category/config.svg create mode 100644 dojo/static/dojo/img/watercolor/category/data.svg create mode 100644 dojo/static/dojo/img/watercolor/category/identity.svg create mode 100644 dojo/static/dojo/img/watercolor/category/supply.svg create mode 100644 dojo/static/dojo/img/watercolor/category/web.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/critical-1.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/critical-2.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/critical-3.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/critical-4.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/critical-5.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/high-1.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/high-2.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/high-3.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/high-4.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/high-5.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/info-1.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/info-2.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/info-3.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/info-4.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/info-5.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/low-1.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/low-2.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/low-3.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/low-4.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/low-5.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/medium-1.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/medium-2.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/medium-3.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/medium-4.svg create mode 100644 dojo/static/dojo/img/watercolor/covers/medium-5.svg create mode 100644 dojo/static/dojo/js/watercolor.js create mode 100644 dojo/templates/dojo/watercolor/_base.html create mode 100644 dojo/templates/dojo/watercolor/_partials/_card.html create mode 100644 dojo/templates/dojo/watercolor/_partials/_category_tile.html create mode 100644 dojo/templates/dojo/watercolor/_partials/_counter_cell.html create mode 100644 dojo/templates/dojo/watercolor/_partials/_drawer.html create mode 100644 dojo/templates/dojo/watercolor/_partials/_top_nav.html create mode 100644 dojo/templates/dojo/watercolor/browse.html create mode 100644 dojo/templates/dojo/watercolor/home.html create mode 100644 dojo/templates/dojo/watercolor/queue.html create mode 100644 dojo/templates/dojo/watercolor/reports.html create mode 100644 dojo/watercolor/__init__.py create mode 100644 dojo/watercolor/apps.py create mode 100644 dojo/watercolor/templatetags/__init__.py create mode 100644 dojo/watercolor/templatetags/watercolor.py create mode 100644 dojo/watercolor/urls.py create mode 100644 dojo/watercolor/views.py create mode 100644 unittests/test_watercolor_views.py diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index a1cd0cc5501..60ad14b86b2 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -760,6 +760,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "pgtrigger", "pghistory", "single_session", + "dojo.watercolor.apps.WatercolorConfig", ) # ------------------------------------------------------------------------------ diff --git a/dojo/static/dojo/css/watercolor.css b/dojo/static/dojo/css/watercolor.css new file mode 100644 index 00000000000..a5843bef4b6 --- /dev/null +++ b/dojo/static/dojo/css/watercolor.css @@ -0,0 +1,787 @@ +/* Copyright (c) Tactivos / Mural. Licensed under BSD-3-Clause (see LICENSE). */ +/* + * Watercolor FE redesign — all tokens and component styles scoped under + * .theme-watercolor so they cannot leak into the rest of DefectDojo. + */ + +body.theme-watercolor { + /* Palette (keep the `--g-*` names from the old forest-green system) */ + --g-50: #f7fafc; + --g-100: #eef3f7; + --g-200: #dde6ee; + --g-300: #c2d2df; + --g-400: #9bb2c3; + --g-500: #6f8b9f; + --g-600: #4f6b80; + --g-700: #3a5366; + --g-800: #283e4f; + --g-900: #182a38; + + --brand: #5a9ec4; + --brand-hover: #346a8b; + + /* Surfaces */ + --bg-app: #f5f0e8; + --bg-surface: #ffffff; + --bg-tint: #f1ece2; + --bg-cream: #fbf5ea; + + /* Text */ + --text: #1f2a33; + --text-soft: #4a5b6b; + --text-mute: #7d8b97; + --line: rgba(31, 42, 51, 0.08); + + /* Severity washes (pastels) */ + --sev-critical: #f7d6d2; + --sev-high: #f7e3c8; + --sev-medium: #f5edc0; + --sev-low: #d8ead4; + --sev-info: #d5e3ee; + + /* Category gradients */ + --cat-web: linear-gradient(135deg, #dbeaf3, #b9d4e6); + --cat-cloud: linear-gradient(135deg, #f1e3d2, #e9d2b4); + --cat-supply: linear-gradient(135deg, #f0d8d3, #e2b8af); + --cat-identity: linear-gradient(135deg, #e3dded, #cbc1de); + --cat-data: linear-gradient(135deg, #d8e8eb, #b3d2d8); + --cat-config: linear-gradient(135deg, #dfead8, #c2d8b8); + + /* Hero gradient */ + --hero-gradient: linear-gradient(135deg, #f5e8d6 0%, #e9d6e2 45%, #d8e6ec 100%); + + /* Wash families for report cards */ + --wash-sky: linear-gradient(135deg, #dceaf3, #b9d4e6); + --wash-mint: linear-gradient(135deg, #dfead8, #b9d6b3); + --wash-lilac: linear-gradient(135deg, #e3dded, #c3b6dd); + --wash-rose: linear-gradient(135deg, #f7d6d2, #e6b5b0); + + /* Radii */ + --r-sm: 8px; + --r-md: 14px; + --r-lg: 20px; + --r-xl: 28px; + + /* Shadows */ + --shadow-card: 0 6px 18px rgba(24, 42, 56, 0.08); + --shadow-lift: 0 12px 32px rgba(24, 42, 56, 0.14); + --shadow-pop: 0 24px 60px rgba(24, 42, 56, 0.22); + + /* Motion */ + --easing: cubic-bezier(0.4, 0, 0.2, 1); + --d-fast: 140ms; + --d-base: 240ms; + --d-slow: 420ms; + + /* Type */ + --font-display: Georgia, "Times New Roman", serif; + --font-ui: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --h1: 48px; + --h2: 26px; + + background: var(--bg-app); + color: var(--text); + font-family: var(--font-ui); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +body.theme-watercolor #wrapper, +body.theme-watercolor #content-wrapper { + background: transparent; + padding: 0; +} + +body.theme-watercolor .wc-shell { + max-width: 1320px; + margin: 0 auto; + padding: 24px 32px 96px; + animation: wc-fade-in var(--d-slow) var(--easing) both; +} + +@keyframes wc-fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ---------- Top nav ---------- */ +body.theme-watercolor .wc-topnav { + background: var(--bg-surface); + border-bottom: 1px solid var(--line); + padding: 14px 32px 0; + position: sticky; + top: 0; + z-index: 50; +} +body.theme-watercolor .wc-topnav__row { + display: flex; + align-items: center; + gap: 24px; +} +body.theme-watercolor .wc-brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: var(--text); +} +body.theme-watercolor .wc-brand__mark { + width: 36px; height: 36px; + display: inline-flex; align-items: center; justify-content: center; + border-radius: var(--r-sm); + background: linear-gradient(135deg, var(--brand), var(--brand-hover)); + color: #fff; + font-family: var(--font-display); + font-weight: 700; + font-size: 18px; +} +body.theme-watercolor .wc-brand__title { display: block; font-family: var(--font-display); font-size: 18px; } +body.theme-watercolor .wc-brand__sub { display: block; font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-mute); } + +body.theme-watercolor .wc-search { + flex: 1 1 auto; + max-width: 520px; + background: rgba(245, 240, 232, 0.6); + backdrop-filter: blur(8px); + border: 1px solid var(--line); + border-radius: 999px; + padding: 8px 16px; + display: flex; align-items: center; gap: 10px; +} +body.theme-watercolor .wc-search__input { + flex: 1; border: 0; background: transparent; outline: none; + font-size: 14px; color: var(--text); +} +body.theme-watercolor .wc-search__kbd { + font-family: var(--font-ui); + font-size: 11px; + padding: 2px 6px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + color: var(--text-mute); +} + +body.theme-watercolor .wc-actions { display: flex; align-items: center; gap: 12px; } +body.theme-watercolor .wc-action-btn { + position: relative; + background: transparent; + border: 1px solid var(--line); + border-radius: 999px; + width: 38px; height: 38px; + cursor: pointer; +} +body.theme-watercolor .wc-dot { + width: 8px; height: 8px; border-radius: 999px; + display: inline-block; vertical-align: middle; +} +body.theme-watercolor .wc-dot--red { position: absolute; top: 8px; right: 8px; background: #d76661; } +body.theme-watercolor .wc-dot--critical { background: #c25a55; } +body.theme-watercolor .wc-dot--high { background: #d8954e; } +body.theme-watercolor .wc-dot--medium { background: #c5b251; } +body.theme-watercolor .wc-dot--low { background: #6a9a6a; } +body.theme-watercolor .wc-dot--info { background: #6b8aa6; } +body.theme-watercolor .wc-dot--sla { background: #b06a92; } +body.theme-watercolor .wc-dot--fixed { background: #6a9a6a; } + +body.theme-watercolor .wc-badge { + position: absolute; + top: -4px; right: -4px; + background: var(--brand); + color: #fff; + font-size: 11px; + border-radius: 999px; + padding: 2px 6px; + min-width: 18px; + text-align: center; +} + +body.theme-watercolor .wc-avatar-pill { + display: flex; align-items: center; gap: 8px; + background: var(--bg-tint); + border-radius: 999px; + padding: 4px 12px 4px 4px; +} +body.theme-watercolor .wc-avatar-pill__initials { + width: 30px; height: 30px; + border-radius: 999px; + background: var(--brand); + color: #fff; + display: inline-flex; align-items: center; justify-content: center; + font-size: 12px; +} +body.theme-watercolor .wc-avatar-pill__name { display: block; font-size: 13px; } +body.theme-watercolor .wc-avatar-pill__role { display: block; font-size: 11px; color: var(--text-mute); } + +body.theme-watercolor .wc-catbar { + display: flex; gap: 24px; padding: 14px 0; + overflow-x: auto; +} +body.theme-watercolor .wc-catbar__item { + color: var(--text-soft); + text-decoration: none; + font-size: 13px; + padding-bottom: 6px; + border-bottom: 2px solid transparent; + white-space: nowrap; +} +body.theme-watercolor .wc-catbar__item.is-active { + color: var(--text); + border-bottom-color: var(--brand); +} + +/* ---------- Hero ---------- */ +body.theme-watercolor .wc-hero { + display: grid; + grid-template-columns: 1.5fr 1fr; + gap: 32px; + background: var(--hero-gradient); + border-radius: var(--r-xl); + padding: 48px; + margin-bottom: 32px; + box-shadow: var(--shadow-card); +} +body.theme-watercolor .wc-eyebrow { + display: inline-block; + background: rgba(255,255,255,0.6); + padding: 4px 12px; + border-radius: 999px; + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-soft); +} +body.theme-watercolor .wc-h1 { + font-family: var(--font-display); + font-size: var(--h1); + line-height: 1.1; + margin: 16px 0 12px; + color: var(--text); +} +body.theme-watercolor .wc-h1__em { + font-style: italic; + background: linear-gradient(135deg, #d86a6a, #a274c2); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +body.theme-watercolor .wc-h2 { + font-family: var(--font-display); + font-size: var(--h2); + margin: 0 0 16px; + color: var(--text); +} +body.theme-watercolor .wc-hero__body { font-size: 16px; color: var(--text-soft); max-width: 540px; } +body.theme-watercolor .wc-hero__ctas { display: flex; gap: 12px; margin-top: 24px; } + +body.theme-watercolor .wc-btn { + display: inline-flex; align-items: center; justify-content: center; + border-radius: 999px; + padding: 10px 22px; + font-size: 14px; + cursor: pointer; + border: 1px solid transparent; + text-decoration: none; + transition: background var(--d-fast) var(--easing), color var(--d-fast) var(--easing); +} +body.theme-watercolor .wc-btn--primary { background: var(--g-800); color: #fff; } +body.theme-watercolor .wc-btn--primary:hover { background: var(--g-900); } +body.theme-watercolor .wc-btn--ghost { background: rgba(255,255,255,0.6); color: var(--text); border-color: var(--line); } +body.theme-watercolor .wc-btn--ghost:hover { background: #fff; } +body.theme-watercolor .wc-btn--block { width: 100%; } + +body.theme-watercolor .wc-counter-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} +body.theme-watercolor .wc-counter-cell { + background: rgba(255,255,255,0.7); + border-radius: var(--r-md); + padding: 16px; +} +body.theme-watercolor .wc-counter { + font-family: var(--font-display); + font-size: 36px; + color: var(--text); + display: block; +} +body.theme-watercolor .wc-counter-cell__label { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-mute); +} + +/* ---------- Assurance row ---------- */ +body.theme-watercolor .wc-assurance { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0; + background: var(--bg-surface); + border-radius: var(--r-md); + padding: 20px; + box-shadow: var(--shadow-card); + margin-bottom: 40px; +} +body.theme-watercolor .wc-assurance__cell { + font-size: 13px; + color: var(--text-soft); + padding: 0 16px; + border-right: 1px solid var(--line); +} +body.theme-watercolor .wc-assurance__cell:last-child { border-right: 0; } +body.theme-watercolor .wc-assurance__cell strong { color: var(--text); } + +/* ---------- Sections ---------- */ +body.theme-watercolor .wc-section { margin: 40px 0; } + +body.theme-watercolor .wc-cat-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 16px; +} +@media (max-width: 1024px) { + body.theme-watercolor .wc-cat-grid { grid-template-columns: repeat(3, 1fr); } + body.theme-watercolor .wc-hero { grid-template-columns: 1fr; padding: 32px; } +} + +body.theme-watercolor .wc-cat-tile { + display: flex; flex-direction: column; + background: var(--bg-surface); + border-radius: var(--r-md); + overflow: hidden; + box-shadow: var(--shadow-card); + text-decoration: none; + color: var(--text); + transition: transform var(--d-base) var(--easing), box-shadow var(--d-base) var(--easing); +} +body.theme-watercolor .wc-cat-tile:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); } +body.theme-watercolor .wc-cat-tile__header { + height: 60px; + position: relative; + display: flex; justify-content: flex-end; align-items: center; + padding: 0 12px; +} +body.theme-watercolor .wc-cat-tile.cat-web .wc-cat-tile__header { background: var(--cat-web); } +body.theme-watercolor .wc-cat-tile.cat-cloud .wc-cat-tile__header { background: var(--cat-cloud); } +body.theme-watercolor .wc-cat-tile.cat-supply .wc-cat-tile__header { background: var(--cat-supply); } +body.theme-watercolor .wc-cat-tile.cat-identity .wc-cat-tile__header { background: var(--cat-identity); } +body.theme-watercolor .wc-cat-tile.cat-data .wc-cat-tile__header { background: var(--cat-data); } +body.theme-watercolor .wc-cat-tile.cat-config .wc-cat-tile__header { background: var(--cat-config); } +body.theme-watercolor .wc-cat-tile__icon { width: 40px; height: 40px; opacity: 0.85; } +body.theme-watercolor .wc-cat-tile__body { padding: 14px 16px 18px; } +body.theme-watercolor .wc-cat-tile__name { display: block; font-size: 14px; font-weight: 600; } +body.theme-watercolor .wc-cat-tile__count { display: block; font-size: 12px; color: var(--text-mute); margin-top: 2px; } + +/* ---------- Card grids ---------- */ +body.theme-watercolor .wc-card-grid { + display: grid; + gap: 20px; +} +body.theme-watercolor .wc-card-grid--4up { grid-template-columns: repeat(4, 1fr); } +body.theme-watercolor .wc-card-grid--browse { grid-template-columns: repeat(3, 1fr); } +@media (max-width: 1024px) { + body.theme-watercolor .wc-card-grid--4up, + body.theme-watercolor .wc-card-grid--browse { grid-template-columns: repeat(2, 1fr); } +} +body.theme-watercolor .wc-card-scroller { + grid-auto-flow: column; + grid-auto-columns: 260px; + overflow-x: auto; + padding-bottom: 12px; +} + +/* ---------- Finding card ---------- */ +body.theme-watercolor .wc-card { + position: relative; + background: var(--bg-surface); + border-radius: var(--r-md); + overflow: hidden; + box-shadow: var(--shadow-card); + cursor: pointer; + transition: transform var(--d-base) var(--easing), box-shadow var(--d-base) var(--easing), opacity var(--d-base) var(--easing); + opacity: 0; + animation: wc-card-in var(--d-slow) var(--easing) forwards; +} +body.theme-watercolor .wc-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-lift); } +body.theme-watercolor .wc-card__cover { + aspect-ratio: 16 / 7; + background-size: cover; + background-position: center; + background-color: var(--bg-tint); +} +@keyframes wc-card-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } + +body.theme-watercolor .wc-card.severity-critical .wc-card__cover { background-color: var(--sev-critical); } +body.theme-watercolor .wc-card.severity-high .wc-card__cover { background-color: var(--sev-high); } +body.theme-watercolor .wc-card.severity-medium .wc-card__cover { background-color: var(--sev-medium); } +body.theme-watercolor .wc-card.severity-low .wc-card__cover { background-color: var(--sev-low); } +body.theme-watercolor .wc-card.severity-info .wc-card__cover { background-color: var(--sev-info); } + +body.theme-watercolor .wc-heart { + position: absolute; + top: 12px; right: 12px; + width: 36px; height: 36px; + border-radius: 999px; + background: rgba(255,255,255,0.85); + border: 0; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + color: var(--text-mute); + transition: color var(--d-fast) var(--easing), transform var(--d-fast) var(--easing); +} +body.theme-watercolor .wc-heart.is-saved { color: #d76661; } +body.theme-watercolor .wc-heart:hover { transform: scale(1.08); } +body.theme-watercolor .wc-heart__glyph { font-size: 18px; line-height: 1; } + +body.theme-watercolor .wc-card__body { padding: 16px 18px 20px; } +body.theme-watercolor .wc-chip { + display: inline-block; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text); +} +body.theme-watercolor .wc-card.severity-critical .wc-chip--severity { background: var(--sev-critical); } +body.theme-watercolor .wc-card.severity-high .wc-chip--severity { background: var(--sev-high); } +body.theme-watercolor .wc-card.severity-medium .wc-chip--severity { background: var(--sev-medium); } +body.theme-watercolor .wc-card.severity-low .wc-chip--severity { background: var(--sev-low); } +body.theme-watercolor .wc-card.severity-info .wc-chip--severity { background: var(--sev-info); } + +body.theme-watercolor .wc-card__title { + font-family: var(--font-display); + font-size: 17px; + margin: 10px 0 8px; + line-height: 1.3; + color: var(--text); +} +body.theme-watercolor .wc-card__meta { + display: flex; gap: 12px; + color: var(--text-mute); + font-size: 12px; + align-items: baseline; +} +body.theme-watercolor .wc-cvss { + font-family: var(--font-display); + font-size: 22px; + color: var(--text); +} + +/* ---------- Browse ---------- */ +body.theme-watercolor .wc-browse { + display: grid; + grid-template-columns: 260px 1fr; + gap: 32px; +} +@media (max-width: 1024px) { + body.theme-watercolor .wc-browse { grid-template-columns: 1fr; } +} +body.theme-watercolor .wc-filters { + position: sticky; + top: 132px; + align-self: flex-start; + background: var(--bg-surface); + border-radius: var(--r-md); + padding: 20px; + box-shadow: var(--shadow-card); + max-height: calc(100vh - 160px); + overflow: auto; +} +body.theme-watercolor .wc-filter-group { + border: 0; + margin: 0 0 20px; + padding: 0; +} +body.theme-watercolor .wc-filter-group legend { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-mute); + margin-bottom: 8px; +} +body.theme-watercolor .wc-check, body.theme-watercolor .wc-radio { + display: flex; align-items: center; gap: 8px; + font-size: 13px; + margin: 4px 0; +} +body.theme-watercolor .wc-pill-toggle { + display: inline-flex; align-items: center; gap: 6px; + margin-right: 8px; + font-size: 12px; +} + +body.theme-watercolor .wc-results__head { + display: flex; align-items: center; gap: 16px; + margin-bottom: 16px; +} +body.theme-watercolor .wc-results__count { + font-size: 20px; + color: var(--text-mute); +} +body.theme-watercolor .wc-sort { + margin-left: auto; + display: flex; align-items: center; gap: 8px; + font-size: 13px; +} +body.theme-watercolor .wc-chip-row { + display: flex; flex-wrap: wrap; gap: 8px; + list-style: none; + padding: 0; + margin: 0 0 16px; +} +body.theme-watercolor .wc-chip--active { + background: var(--bg-tint); + border: 1px solid var(--line); +} + +body.theme-watercolor .wc-pagination { + display: flex; gap: 12px; align-items: center; + margin-top: 24px; + justify-content: center; +} +body.theme-watercolor .wc-pagination__link { + background: var(--bg-surface); + border: 1px solid var(--line); + border-radius: 999px; + padding: 6px 14px; + text-decoration: none; + color: var(--text); +} + +/* ---------- Queue ---------- */ +body.theme-watercolor .wc-queue__head { + display: flex; align-items: center; gap: 16px; + margin-bottom: 24px; +} +body.theme-watercolor .wc-queue__count { + font-size: 24px; + color: var(--text-mute); +} +body.theme-watercolor .wc-empty { + background: var(--bg-surface); + border: 1px dashed var(--line); + border-radius: var(--r-md); + padding: 32px; + text-align: center; + color: var(--text-mute); +} +body.theme-watercolor .wc-empty--state { + background: var(--bg-cream); + font-family: var(--font-display); + font-size: 18px; + color: var(--text-soft); +} + +/* ---------- Reports ---------- */ +body.theme-watercolor .wc-reports__head { margin-bottom: 24px; } +body.theme-watercolor .wc-reports__sub { color: var(--text-soft); } +body.theme-watercolor .wc-report-card { + background: var(--bg-surface); + border-radius: var(--r-md); + overflow: hidden; + box-shadow: var(--shadow-card); + display: flex; flex-direction: column; +} +body.theme-watercolor .wc-report-card__cover { + aspect-ratio: 16 / 9; + background: var(--cat-web); +} +body.theme-watercolor .wc-report-card.wash-sky .wc-report-card__cover { background: var(--wash-sky); } +body.theme-watercolor .wc-report-card.wash-mint .wc-report-card__cover { background: var(--wash-mint); } +body.theme-watercolor .wc-report-card.wash-lilac .wc-report-card__cover { background: var(--wash-lilac); } +body.theme-watercolor .wc-report-card.wash-rose .wc-report-card__cover { background: var(--wash-rose); } +body.theme-watercolor .wc-report-card__body { padding: 20px 22px 24px; } +body.theme-watercolor .wc-report-card__cat { + display: inline-block; font-size: 11px; letter-spacing: 0.1em; + text-transform: uppercase; color: var(--text-mute); +} +body.theme-watercolor .wc-report-card__title { + font-family: var(--font-display); + font-size: 20px; + margin: 6px 0 10px; +} + +/* ---------- Drawer ---------- */ +body.theme-watercolor .wc-drawer { + position: fixed; + top: 0; right: 0; bottom: 0; + width: 640px; + max-width: 92vw; + background: var(--bg-surface); + box-shadow: var(--shadow-pop); + transform: translateX(110%); + transition: transform var(--d-base) var(--easing); + z-index: 100; + overflow: hidden; +} +body.theme-watercolor .wc-drawer.is-open { transform: translateX(0); } +body.theme-watercolor .wc-drawer__inner { + height: 100%; + overflow: auto; +} +body.theme-watercolor .wc-drawer__head { + position: sticky; + top: 0; + padding: 16px 24px; + display: flex; align-items: center; justify-content: space-between; + background: var(--bg-surface); + border-bottom: 1px solid var(--line); + z-index: 1; +} +body.theme-watercolor .wc-drawer__actions { display: flex; gap: 8px; } +body.theme-watercolor .wc-drawer__icon { + background: transparent; border: 1px solid var(--line); + border-radius: 999px; + width: 34px; height: 34px; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; +} +body.theme-watercolor .wc-drawer__cover { + aspect-ratio: 16 / 7; + background-size: cover; + background-position: center; +} +body.theme-watercolor .wc-drawer__thumbs { + display: flex; gap: 8px; padding: 12px 24px; +} +body.theme-watercolor .wc-drawer__thumb { + width: 72px; height: 40px; + border-radius: var(--r-sm); + background-size: cover; background-position: center; + opacity: 0.6; + border: 2px solid transparent; +} +body.theme-watercolor .wc-drawer__thumb.is-active { opacity: 1; border-color: var(--brand); } +body.theme-watercolor .wc-drawer__body { padding: 8px 24px 48px; } +body.theme-watercolor .wc-drawer__title-row { + display: flex; align-items: baseline; gap: 12px; + flex-wrap: wrap; +} +body.theme-watercolor .wc-drawer__title { + font-family: var(--font-display); + font-size: 26px; + margin: 12px 0 4px; +} +body.theme-watercolor .wc-pill { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background: var(--bg-tint); + font-size: 11px; + color: var(--text-soft); +} +body.theme-watercolor .wc-drawer__sub { color: var(--text-soft); margin: 0 0 14px; } +body.theme-watercolor .wc-drawer__cvss { + display: inline-flex; align-items: baseline; gap: 8px; + background: var(--bg-cream); + padding: 10px 16px; + border-radius: var(--r-md); + margin: 8px 0 16px; +} +body.theme-watercolor .wc-drawer__cvss-label { font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-mute); } +body.theme-watercolor .wc-drawer__cvss-value { font-family: var(--font-display); font-size: 28px; } +body.theme-watercolor .wc-drawer__cta-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 24px; } +body.theme-watercolor .wc-drawer__section { margin: 20px 0; } +body.theme-watercolor .wc-drawer__h { + font-family: var(--font-display); + font-size: 16px; + color: var(--text-soft); + margin: 0 0 8px; +} +body.theme-watercolor .wc-drawer__proof { + background: var(--g-50); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: 12px; + font-size: 12px; + overflow: auto; + color: var(--text); +} +body.theme-watercolor .wc-drawer__details { + display: grid; + grid-template-columns: 120px 1fr; + gap: 6px 16px; + font-size: 13px; +} +body.theme-watercolor .wc-drawer__details dt { color: var(--text-mute); } +body.theme-watercolor .wc-tags, body.theme-watercolor .wc-avatars { + display: flex; flex-wrap: wrap; gap: 6px; + list-style: none; padding: 0; margin: 0; +} +body.theme-watercolor .wc-tag { + background: var(--bg-tint); + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + color: var(--text-soft); +} +body.theme-watercolor .wc-avatar { + width: 28px; height: 28px; + border-radius: 999px; + background: var(--brand); + color: #fff; + display: inline-flex; align-items: center; justify-content: center; + font-size: 11px; +} + +/* ---------- Toast ---------- */ +body.theme-watercolor .wc-toast { + position: fixed; + bottom: 24px; left: 50%; + transform: translateX(-50%) translateY(40px); + background: var(--g-800); + color: #fff; + padding: 10px 20px; + border-radius: 999px; + font-size: 13px; + opacity: 0; + transition: opacity var(--d-base) var(--easing), transform var(--d-base) var(--easing); + z-index: 200; +} +body.theme-watercolor .wc-toast.is-visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +/* ---------- Command palette ---------- */ +body.theme-watercolor .wc-palette { + position: fixed; inset: 0; + background: rgba(24, 42, 56, 0.36); + display: none; + align-items: flex-start; + justify-content: center; + padding-top: 14vh; + z-index: 300; +} +body.theme-watercolor .wc-palette.is-open { display: flex; } +body.theme-watercolor .wc-palette__card { + width: min(540px, 92vw); + background: var(--bg-surface); + border-radius: var(--r-lg); + box-shadow: var(--shadow-pop); + overflow: hidden; +} +body.theme-watercolor .wc-palette__input { + width: 100%; + padding: 16px 20px; + border: 0; + font-size: 15px; + outline: none; +} +body.theme-watercolor .wc-palette__hint { + padding: 4px 20px 8px; + font-size: 11px; + color: var(--text-mute); +} +body.theme-watercolor .wc-palette__list { + margin: 0; padding: 8px 0 16px; + list-style: none; +} +body.theme-watercolor .wc-palette__item { + padding: 8px 20px; + font-size: 13px; + color: var(--text-mute); +} diff --git a/dojo/static/dojo/img/watercolor/category/cloud.svg b/dojo/static/dojo/img/watercolor/category/cloud.svg new file mode 100644 index 00000000000..bda0dc86e32 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/category/cloud.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/category/config.svg b/dojo/static/dojo/img/watercolor/category/config.svg new file mode 100644 index 00000000000..ce2971d8845 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/category/config.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/category/data.svg b/dojo/static/dojo/img/watercolor/category/data.svg new file mode 100644 index 00000000000..8561cb33c71 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/category/data.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/category/identity.svg b/dojo/static/dojo/img/watercolor/category/identity.svg new file mode 100644 index 00000000000..e09db0cb4f0 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/category/identity.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/category/supply.svg b/dojo/static/dojo/img/watercolor/category/supply.svg new file mode 100644 index 00000000000..81e2eb07b73 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/category/supply.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/category/web.svg b/dojo/static/dojo/img/watercolor/category/web.svg new file mode 100644 index 00000000000..7af33059998 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/category/web.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/critical-1.svg b/dojo/static/dojo/img/watercolor/covers/critical-1.svg new file mode 100644 index 00000000000..b25dd9d88ff --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/critical-1.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/critical-2.svg b/dojo/static/dojo/img/watercolor/covers/critical-2.svg new file mode 100644 index 00000000000..44f7f8a0ba6 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/critical-2.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/critical-3.svg b/dojo/static/dojo/img/watercolor/covers/critical-3.svg new file mode 100644 index 00000000000..f3232ba87e4 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/critical-3.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/critical-4.svg b/dojo/static/dojo/img/watercolor/covers/critical-4.svg new file mode 100644 index 00000000000..4b8def38128 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/critical-4.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/critical-5.svg b/dojo/static/dojo/img/watercolor/covers/critical-5.svg new file mode 100644 index 00000000000..ff779034661 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/critical-5.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/high-1.svg b/dojo/static/dojo/img/watercolor/covers/high-1.svg new file mode 100644 index 00000000000..70bdd9f32a6 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/high-1.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/high-2.svg b/dojo/static/dojo/img/watercolor/covers/high-2.svg new file mode 100644 index 00000000000..c66fe689eea --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/high-2.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/high-3.svg b/dojo/static/dojo/img/watercolor/covers/high-3.svg new file mode 100644 index 00000000000..d18b6b6bfb5 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/high-3.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/high-4.svg b/dojo/static/dojo/img/watercolor/covers/high-4.svg new file mode 100644 index 00000000000..57098370914 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/high-4.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/high-5.svg b/dojo/static/dojo/img/watercolor/covers/high-5.svg new file mode 100644 index 00000000000..d25de5e948b --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/high-5.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/info-1.svg b/dojo/static/dojo/img/watercolor/covers/info-1.svg new file mode 100644 index 00000000000..f0d05ce6ef3 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/info-1.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/info-2.svg b/dojo/static/dojo/img/watercolor/covers/info-2.svg new file mode 100644 index 00000000000..a6dca2b4c22 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/info-2.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/info-3.svg b/dojo/static/dojo/img/watercolor/covers/info-3.svg new file mode 100644 index 00000000000..da6a9592ad2 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/info-3.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/info-4.svg b/dojo/static/dojo/img/watercolor/covers/info-4.svg new file mode 100644 index 00000000000..e3b921a5660 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/info-4.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/info-5.svg b/dojo/static/dojo/img/watercolor/covers/info-5.svg new file mode 100644 index 00000000000..6a735fc72b7 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/info-5.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/low-1.svg b/dojo/static/dojo/img/watercolor/covers/low-1.svg new file mode 100644 index 00000000000..826ef224dd9 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/low-1.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/low-2.svg b/dojo/static/dojo/img/watercolor/covers/low-2.svg new file mode 100644 index 00000000000..04f03379b84 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/low-2.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/low-3.svg b/dojo/static/dojo/img/watercolor/covers/low-3.svg new file mode 100644 index 00000000000..4e6f9048afb --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/low-3.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/low-4.svg b/dojo/static/dojo/img/watercolor/covers/low-4.svg new file mode 100644 index 00000000000..b17d76e53ee --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/low-4.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/low-5.svg b/dojo/static/dojo/img/watercolor/covers/low-5.svg new file mode 100644 index 00000000000..05b13424492 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/low-5.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/medium-1.svg b/dojo/static/dojo/img/watercolor/covers/medium-1.svg new file mode 100644 index 00000000000..e6f8832dd4b --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/medium-1.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/medium-2.svg b/dojo/static/dojo/img/watercolor/covers/medium-2.svg new file mode 100644 index 00000000000..f5dbdd3892c --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/medium-2.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/medium-3.svg b/dojo/static/dojo/img/watercolor/covers/medium-3.svg new file mode 100644 index 00000000000..3394d79e791 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/medium-3.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/medium-4.svg b/dojo/static/dojo/img/watercolor/covers/medium-4.svg new file mode 100644 index 00000000000..5a6c0cc9259 --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/medium-4.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/img/watercolor/covers/medium-5.svg b/dojo/static/dojo/img/watercolor/covers/medium-5.svg new file mode 100644 index 00000000000..516b2be36ba --- /dev/null +++ b/dojo/static/dojo/img/watercolor/covers/medium-5.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/dojo/static/dojo/js/watercolor.js b/dojo/static/dojo/js/watercolor.js new file mode 100644 index 00000000000..ae5755a7f84 --- /dev/null +++ b/dojo/static/dojo/js/watercolor.js @@ -0,0 +1,208 @@ +// Copyright (c) Tactivos / Mural. Licensed under BSD-3-Clause (see LICENSE). +// Watercolor FE behaviors: counters, bookmarks, drawer, command palette, stagger. +(function () { + "use strict"; + + var STAGGER_CAP = 6; + var STAGGER_STEP_MS = 35; + var COUNTER_DURATION_MS = 800; + + function getCookie(name) { + var match = document.cookie.match(new RegExp("(^|; )" + name + "=([^;]+)")); + return match ? decodeURIComponent(match[2]) : ""; + } + + function easeOutCubic(t) { + return 1 - Math.pow(1 - t, 3); + } + + function animateCount(el, target, duration) { + var start = null; + target = Math.max(0, parseInt(target, 10) || 0); + function step(ts) { + if (start === null) { start = ts; } + var pct = Math.min(1, (ts - start) / duration); + var v = Math.round(easeOutCubic(pct) * target); + el.textContent = v; + if (pct < 1) { + requestAnimationFrame(step); + } else { + el.textContent = target; + } + } + requestAnimationFrame(step); + } + + function showToast(msg, isError) { + var t = document.getElementById("wc-toast"); + if (!t) { return; } + t.textContent = msg; + t.classList.toggle("wc-toast--error", !!isError); + t.classList.add("is-visible"); + setTimeout(function () { t.classList.remove("is-visible"); }, 2200); + } + + function refreshBadge() { + fetch("/api/v2/bookmarks/count/", { + credentials: "same-origin", + headers: { "Accept": "application/json" }, + }).then(function (r) { + return r.ok ? r.json() : null; + }).then(function (data) { + if (data && typeof data.count !== "undefined") { + var badge = document.getElementById("wc-bookmark-count"); + if (badge) { badge.textContent = data.count; } + } + }).catch(function () { /* swallow */ }); + } + + function initCounters() { + var nodes = document.querySelectorAll(".wc-counter[data-value]"); + nodes.forEach(function (el) { + var target = el.getAttribute("data-value"); + animateCount(el, target, COUNTER_DURATION_MS); + }); + } + + function initBookmarks() { + document.body.addEventListener("click", function (e) { + var btn = e.target.closest(".wc-heart"); + if (!btn) { return; } + e.preventDefault(); + e.stopPropagation(); + var fid = btn.getAttribute("data-finding-id"); + if (!fid) { return; } + var saved = btn.classList.contains("is-saved"); + var url = saved ? "/api/v2/bookmarks/" + fid + "/" : "/api/v2/bookmarks/"; + var opts = { + method: saved ? "DELETE" : "POST", + credentials: "same-origin", + headers: { + "X-CSRFToken": getCookie("csrftoken"), + "Content-Type": "application/json", + "Accept": "application/json", + }, + }; + if (!saved) { opts.body = JSON.stringify({ finding: parseInt(fid, 10) }); } + + // Optimistic toggle. + btn.classList.toggle("is-saved"); + btn.setAttribute("aria-pressed", saved ? "false" : "true"); + + fetch(url, opts).then(function (r) { + if (!r.ok) { throw new Error("bookmark request failed"); } + showToast(saved ? "Removed from queue." : "Saved to your queue."); + refreshBadge(); + }).catch(function () { + // Roll back optimistic state. + btn.classList.toggle("is-saved"); + btn.setAttribute("aria-pressed", saved ? "true" : "false"); + showToast("Could not update bookmark.", true); + }); + }); + } + + function openDrawer(html) { + var drawer = document.getElementById("wc-drawer"); + var body = document.getElementById("wc-drawer-body"); + if (!drawer || !body) { return; } + body.innerHTML = html; + drawer.classList.add("is-open"); + drawer.setAttribute("aria-hidden", "false"); + var close = document.getElementById("wc-drawer-close"); + if (close) { close.focus(); } + } + + function closeDrawer() { + var drawer = document.getElementById("wc-drawer"); + if (!drawer) { return; } + drawer.classList.remove("is-open"); + drawer.setAttribute("aria-hidden", "true"); + } + + function initDrawer() { + document.body.addEventListener("click", function (e) { + if (e.target.closest(".wc-heart")) { return; } + if (e.target.closest("#wc-drawer-close")) { closeDrawer(); return; } + var card = e.target.closest(".wc-card"); + if (!card) { return; } + var fid = card.getAttribute("data-finding-id"); + if (!fid) { return; } + e.preventDefault(); + fetch("/watercolor/finding/" + fid + "/drawer/", { + credentials: "same-origin", + headers: { "Accept": "text/html" }, + }).then(function (r) { + if (!r.ok) { throw new Error("drawer fetch failed"); } + return r.text(); + }).then(openDrawer) + .catch(function () { showToast("Could not load finding.", true); }); + }); + + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") { closeDrawer(); closePalette(); } + // Tab-trap inside the drawer when open. + var drawer = document.getElementById("wc-drawer"); + if (e.key === "Tab" && drawer && drawer.classList.contains("is-open")) { + var focusables = drawer.querySelectorAll( + "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])" + ); + if (focusables.length === 0) { return; } + var first = focusables[0]; + var last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); first.focus(); + } + } + }); + } + + function openPalette() { + var p = document.getElementById("wc-palette"); + if (!p) { return; } + p.classList.add("is-open"); + p.setAttribute("aria-hidden", "false"); + var input = p.querySelector(".wc-palette__input"); + if (input) { input.focus(); } + } + + function closePalette() { + var p = document.getElementById("wc-palette"); + if (!p) { return; } + p.classList.remove("is-open"); + p.setAttribute("aria-hidden", "true"); + } + + function initPalette() { + document.addEventListener("keydown", function (e) { + if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) { + e.preventDefault(); + openPalette(); + } + }); + } + + function initStagger() { + var grids = document.querySelectorAll(".wc-card-grid"); + grids.forEach(function (grid) { + var children = grid.children; + for (var i = 0; i < children.length; i++) { + var idx = Math.min(i, STAGGER_CAP); + children[i].style.animationDelay = (idx * STAGGER_STEP_MS) + "ms"; + children[i].style.transitionDelay = (idx * STAGGER_STEP_MS) + "ms"; + } + }); + } + + function initAll() { + initCounters(); + initBookmarks(); + initDrawer(); + initPalette(); + initStagger(); + } + + window.WaterColor = { initAll: initAll, openPalette: openPalette, closePalette: closePalette }; +})(); diff --git a/dojo/templates/dojo/watercolor/_base.html b/dojo/templates/dojo/watercolor/_base.html new file mode 100644 index 00000000000..e5092166e7f --- /dev/null +++ b/dojo/templates/dojo/watercolor/_base.html @@ -0,0 +1,40 @@ + +{% extends "base.html" %} +{% load static %} +{% load watercolor %} + +{% block add_css %} + {{ block.super }} + +{% endblock %} + +{% block navigation %} + {# Replace the upstream side-nav with the watercolor top bar. #} + {% include "dojo/watercolor/_partials/_top_nav.html" %} +{% endblock %} + +{% block pre_wrapper %} + {# Apply the theme class via a JS shim since base.html bakes body class. #} + +{% endblock %} + +{% block content %} +
+ {% block wc_content %}{% endblock %} +
+ +
+ + + +{% endblock %} diff --git a/dojo/templates/dojo/watercolor/_partials/_card.html b/dojo/templates/dojo/watercolor/_partials/_card.html new file mode 100644 index 00000000000..0e26d18aadb --- /dev/null +++ b/dojo/templates/dojo/watercolor/_partials/_card.html @@ -0,0 +1,23 @@ + +{% load watercolor %} +{% is_bookmarked finding as is_saved %} +
+ + +
+ {{ finding.severity }} +

{{ finding.title }}

+
+ {% if finding.cvssv3_score %}{{ finding.cvssv3_score|floatformat:1 }}{% else %}--{% endif %} + {% age_days finding %}d old + {% due_in finding as due %} + {% if due %}{{ due }}{% endif %} +
+
+
diff --git a/dojo/templates/dojo/watercolor/_partials/_category_tile.html b/dojo/templates/dojo/watercolor/_partials/_category_tile.html new file mode 100644 index 00000000000..68f3982c2b6 --- /dev/null +++ b/dojo/templates/dojo/watercolor/_partials/_category_tile.html @@ -0,0 +1,11 @@ + +{% load watercolor %} + +
+ +
+
+ {% category_display entry.id %} + {{ entry.count }} open +
+
diff --git a/dojo/templates/dojo/watercolor/_partials/_counter_cell.html b/dojo/templates/dojo/watercolor/_partials/_counter_cell.html new file mode 100644 index 00000000000..cc742333cb4 --- /dev/null +++ b/dojo/templates/dojo/watercolor/_partials/_counter_cell.html @@ -0,0 +1,5 @@ + +
+ 0 + {{ label }} +
diff --git a/dojo/templates/dojo/watercolor/_partials/_drawer.html b/dojo/templates/dojo/watercolor/_partials/_drawer.html new file mode 100644 index 00000000000..1b0679c89c4 --- /dev/null +++ b/dojo/templates/dojo/watercolor/_partials/_drawer.html @@ -0,0 +1,69 @@ + +{% load watercolor %} +
+ {{ finding.severity }} +
+ + + +
+
+ + +
+
+

{{ finding.title }}

+ {% if finding.cve %}{{ finding.cve }}{% endif %} +
+

+ found in + {% if product %}{{ product.name }}{% else %}unknown product{% endif %} +

+
+ CVSS + {% if finding.cvssv3_score %}{{ finding.cvssv3_score|floatformat:1 }}{% else %}--{% endif %} +
+
+ + + +
+
+

Proof

+
{{ finding.steps_to_reproduce|default:"No proof captured yet." }}
+
+
+

Details

+
+
CWE
{% if finding.cwe %}CWE-{{ finding.cwe }}{% else %}--{% endif %}
+
Scanner
{% if finding.test.scan_type %}{{ finding.test.scan_type }}{% else %}--{% endif %}
+
Found
{{ finding.date|default:"--" }}
+
SLA due
{{ finding.sla_expiration_date|default:"--" }}
+
Status
{% if finding.is_mitigated %}Mitigated{% elif finding.active %}Active{% else %}Inactive{% endif %}
+
+
+ {% if finding.tags.all %} +
+

Tags

+
    + {% for tag in finding.tags.all %}
  • {{ tag }}
  • {% endfor %} +
+
+ {% endif %} + {% if finding.reviewers.all %} +
+

Reviewers

+
    + {% for r in finding.reviewers.all %} +
  • {{ r.username|slice:":2"|upper }}
  • + {% endfor %} +
+
+ {% endif %} +
diff --git a/dojo/templates/dojo/watercolor/_partials/_top_nav.html b/dojo/templates/dojo/watercolor/_partials/_top_nav.html new file mode 100644 index 00000000000..b76d107aca4 --- /dev/null +++ b/dojo/templates/dojo/watercolor/_partials/_top_nav.html @@ -0,0 +1,44 @@ + +{% load static %} +{% load watercolor %} + diff --git a/dojo/templates/dojo/watercolor/browse.html b/dojo/templates/dojo/watercolor/browse.html new file mode 100644 index 00000000000..2ec1010602d --- /dev/null +++ b/dojo/templates/dojo/watercolor/browse.html @@ -0,0 +1,86 @@ + +{% extends "dojo/watercolor/_base.html" %} +{% load watercolor %} + +{% block wc_content %} +
+ + +
+
+

Browse findings

+ {{ total_count }} +
+ {% for k, v in request.GET.lists %}{% if k != 'order_by' and k != 'page' %}{% for val in v %}{% endfor %}{% endif %}{% endfor %} + + +
+
+ + {% if active_filters %} +
    + {% for f in active_filters %} +
  • {{ f.key }}: {{ f.value }}
  • + {% endfor %} +
+ {% endif %} + +
+ {% for finding in page_obj %} + {% include "dojo/watercolor/_partials/_card.html" with finding=finding %} + {% empty %} +

No findings match these filters.

+ {% endfor %} +
+ + {% if page_obj.paginator.num_pages > 1 %} + + {% endif %} +
+
+{% endblock %} diff --git a/dojo/templates/dojo/watercolor/home.html b/dojo/templates/dojo/watercolor/home.html new file mode 100644 index 00000000000..99b1c6ef373 --- /dev/null +++ b/dojo/templates/dojo/watercolor/home.html @@ -0,0 +1,75 @@ + +{% extends "dojo/watercolor/_base.html" %} +{% load watercolor %} + +{% block wc_content %} +
+
+ Today's triage +

Lead with what to triage right now.

+

A calmer surface for the findings that actually need you today. Saved, assigned, and SLA-breach work, surfaced first.

+ +
+
+

In your queue right now

+
+ {% include "dojo/watercolor/_partials/_counter_cell.html" with label="Critical" value=counters.critical slug="critical" %} + {% include "dojo/watercolor/_partials/_counter_cell.html" with label="High" value=counters.high slug="high" %} + {% include "dojo/watercolor/_partials/_counter_cell.html" with label="SLA breach" value=counters.sla_breach slug="sla" %} + {% include "dojo/watercolor/_partials/_counter_cell.html" with label="Fixed wk" value=counters.fixed_this_week slug="fixed" %} +
+
+
+ +
+
Per-product scope. Counts respect your role.
+
Lossless audit. Every action logs to history.
+
API-first. Same shapes as v2 endpoints.
+
Calm by default. No red until it matters.
+
+ +
+

Vulnerability by category

+
+ {% for entry in category_counts %} + {% include "dojo/watercolor/_partials/_category_tile.html" with entry=entry %} + {% endfor %} +
+
+ +
+

Trending threats this week

+
+ {% for finding in trending %} + {% include "dojo/watercolor/_partials/_card.html" with finding=finding %} + {% empty %} +

Nothing trending this week.

+ {% endfor %} +
+
+ +
+

Picked for you, {{ wc_user_display_name }}

+
+ {% for finding in picked %} + {% include "dojo/watercolor/_partials/_card.html" with finding=finding %} + {% empty %} +

No findings are currently assigned to you.

+ {% endfor %} +
+
+ +
+

Fresh from the scanners

+
+ {% for finding in fresh %} + {% include "dojo/watercolor/_partials/_card.html" with finding=finding %} + {% empty %} +

No findings yet.

+ {% endfor %} +
+
+{% endblock %} diff --git a/dojo/templates/dojo/watercolor/queue.html b/dojo/templates/dojo/watercolor/queue.html new file mode 100644 index 00000000000..aa057fde4d9 --- /dev/null +++ b/dojo/templates/dojo/watercolor/queue.html @@ -0,0 +1,21 @@ + +{% extends "dojo/watercolor/_base.html" %} +{% load watercolor %} + +{% block wc_content %} +
+
+

Your queue

+ {{ total_count }} +
+ {% if items %} +
+ {% for finding in items %} + {% include "dojo/watercolor/_partials/_card.html" with finding=finding %} + {% endfor %} +
+ {% else %} +

{{ empty_copy }}

+ {% endif %} +
+{% endblock %} diff --git a/dojo/templates/dojo/watercolor/reports.html b/dojo/templates/dojo/watercolor/reports.html new file mode 100644 index 00000000000..572668a8b16 --- /dev/null +++ b/dojo/templates/dojo/watercolor/reports.html @@ -0,0 +1,24 @@ + +{% extends "dojo/watercolor/_base.html" %} + +{% block wc_content %} +
+
+

Reports

+

Discoverability shell over the existing DefectDojo report engine.

+
+
+ {% for card in report_cards %} +
+ +
+ {{ card.category }} +

{{ card.title }}

+

{{ card.body }}

+ Generate +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/dojo/urls.py b/dojo/urls.py index 3b5e2a26c38..1a733ca3bd4 100644 --- a/dojo/urls.py +++ b/dojo/urls.py @@ -277,6 +277,8 @@ urlpatterns += [ # action history (audit-log page) — defined in dojo/auditlog/ui/urls.py re_path(r"^", include("dojo.auditlog.ui.urls")), + # Watercolor FE redesign (additive, scoped under /watercolor/). + re_path(r"^{}watercolor/".format(get_system_setting("url_prefix")), include("dojo.watercolor.urls")), re_path(r"^{}".format(get_system_setting("url_prefix")), include(ur)), # drf-spectacular = OpenAPI3 diff --git a/dojo/watercolor/__init__.py b/dojo/watercolor/__init__.py new file mode 100644 index 00000000000..b46ed594ba6 --- /dev/null +++ b/dojo/watercolor/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Tactivos / Mural. Licensed under BSD-3-Clause (see LICENSE). +"""Watercolor FE app: SSR screens for Home, Browse, Queue, Reports, Drawer.""" diff --git a/dojo/watercolor/apps.py b/dojo/watercolor/apps.py new file mode 100644 index 00000000000..025697098b8 --- /dev/null +++ b/dojo/watercolor/apps.py @@ -0,0 +1,13 @@ +# Copyright (c) Tactivos / Mural. Licensed under BSD-3-Clause (see LICENSE). +"""App config for the watercolor FE redesign.""" +from django.apps import AppConfig + + +class WatercolorConfig(AppConfig): + + """Mural watercolor FE app — SSR screens scoped to /watercolor/.""" + + name = "dojo.watercolor" + label = "watercolor" + verbose_name = "Watercolor FE" + default_auto_field = "django.db.models.BigAutoField" diff --git a/dojo/watercolor/templatetags/__init__.py b/dojo/watercolor/templatetags/__init__.py new file mode 100644 index 00000000000..63a9ba127a1 --- /dev/null +++ b/dojo/watercolor/templatetags/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Tactivos / Mural. Licensed under BSD-3-Clause (see LICENSE). diff --git a/dojo/watercolor/templatetags/watercolor.py b/dojo/watercolor/templatetags/watercolor.py new file mode 100644 index 00000000000..4ac8c23084e --- /dev/null +++ b/dojo/watercolor/templatetags/watercolor.py @@ -0,0 +1,114 @@ +# Copyright (c) Tactivos / Mural. Licensed under BSD-3-Clause (see LICENSE). +"""Template tags for the watercolor FE: cover SVGs, severity classes, etc.""" +from __future__ import annotations + +from django import template +from django.templatetags.static import static +from django.utils import timezone + +from dojo.models import CategoryMapping + +register = template.Library() + + +SEVERITY_SLUGS = { + "Critical": "critical", + "High": "high", + "Medium": "medium", + "Low": "low", + "Info": "info", + "Informational": "info", +} + +COVERS_PER_SEVERITY = 5 + + +@register.simple_tag +def cover_svg(finding): + """Return the static URL of the pre-baked SVG cover for ``finding``.""" + severity = SEVERITY_SLUGS.get(getattr(finding, "severity", None), "info") + finding_id = getattr(finding, "id", 0) or 0 + index = (finding_id % COVERS_PER_SEVERITY) + 1 + return static(f"dojo/img/watercolor/covers/{severity}-{index}.svg") + + +@register.simple_tag +def category_icon(category_id): + """Return the static URL of the SVG icon for a category id.""" + return static(f"dojo/img/watercolor/category/{category_id}.svg") + + +@register.simple_tag +def severity_class(severity): + """Map a severity string to a CSS class suffix (``severity-critical`` etc.).""" + slug = SEVERITY_SLUGS.get(severity, "info") + return f"severity-{slug}" + + +@register.simple_tag +def category_display(category_id): + """Human-readable name for a category id.""" + for cid, label in CategoryMapping.CATEGORY_CHOICES: + if cid == category_id: + return label + return category_id or "" + + +@register.simple_tag +def age_days(finding): + """Integer days since ``finding.date`` (or 0 if missing).""" + date = getattr(finding, "date", None) + if not date: + return 0 + now = timezone.now().date() + if hasattr(date, "date"): + date = date.date() + try: + delta = now - date + except TypeError: + return 0 + return max(delta.days, 0) + + +@register.simple_tag +def due_in(finding): + """Human-readable due-in string (e.g. ``14h``, ``4d``, ``21d``); empty if no SLA.""" + sla = getattr(finding, "sla_expiration_date", None) + if not sla: + return "" + today = timezone.now().date() + if hasattr(sla, "date"): + sla = sla.date() + try: + delta = sla - today + except TypeError: + return "" + days = delta.days + if days <= 0: + # Already breached — show hours overdue if within a day, else days. + hours_over = abs(int(delta.total_seconds() // 3600)) + if hours_over < 24: + return f"-{hours_over}h" + return f"{days}d" + if days < 2: + hours = max(int(delta.total_seconds() // 3600), 1) + return f"{hours}h" + return f"{days}d" + + +@register.simple_tag(takes_context=True) +def is_bookmarked(context, finding): + """Return True when ``finding.id`` is in the ``bookmarked_ids`` context set.""" + ids = context.get("bookmarked_ids") or set() + return getattr(finding, "id", None) in ids + + +@register.filter +def get_item(d, key): + """Dict accessor for templates: ``{{ mydict|get_item:key }}``.""" + if d is None: + return None + try: + return d.get(key) + except AttributeError: + return None diff --git a/dojo/watercolor/urls.py b/dojo/watercolor/urls.py new file mode 100644 index 00000000000..4bf02a667be --- /dev/null +++ b/dojo/watercolor/urls.py @@ -0,0 +1,17 @@ +# Copyright (c) Tactivos / Mural. Licensed under BSD-3-Clause (see LICENSE). +"""URL routes for the watercolor FE redesign — additive under /watercolor/.""" +from django.urls import path + +from dojo.watercolor import views + +urlpatterns = [ + path("", views.home, name="watercolor_home"), + path("browse/", views.browse, name="watercolor_browse"), + path("queue/", views.queue, name="watercolor_queue"), + path("reports/", views.reports, name="watercolor_reports"), + path( + "finding//drawer/", + views.drawer, + name="watercolor_drawer", + ), +] diff --git a/dojo/watercolor/views.py b/dojo/watercolor/views.py new file mode 100644 index 00000000000..6b07c00db19 --- /dev/null +++ b/dojo/watercolor/views.py @@ -0,0 +1,352 @@ +# Copyright (c) Tactivos / Mural. Licensed under BSD-3-Clause (see LICENSE). +""" +Watercolor FE views. + +Five SSR screens (Home / Browse / Queue / Reports / Drawer-partial). All +views re-use the queryset helpers backing the existing dashboard ViewSets +so query logic does not duplicate. + +Each view scopes by ``get_authorized_findings(Permissions.Finding_View)`` +which already enforces per-product permissions for the requesting user. +""" +from __future__ import annotations + +from datetime import timedelta + +from django.core.paginator import Paginator +from django.db.models import Count +from django.shortcuts import get_object_or_404, render +from django.utils import timezone + +from dojo.api_v2.dashboard.views import _open_findings, _user_findings +from dojo.authorization.roles_permissions import Permissions +from dojo.finding.queries import get_authorized_findings +from dojo.models import FindingBookmark +from dojo.taxonomy.categorize import CATEGORY_IDS, categorize_queryset + +BROWSE_PAGE_SIZE = 24 +QUEUE_EMPTY_COPY = ( + "Nothing saved yet. Tap the bookmark on any finding to add it here." +) + +SEVERITY_RANK = { + "Critical": 0, + "High": 1, + "Medium": 2, + "Low": 3, + "Info": 4, + "Informational": 4, +} + +AGE_BUCKETS = { + "7d": 7, + "30d": 30, +} + + +def _bookmarked_ids_for(user, findings) -> set[int]: + """Return the set of finding ids the user has bookmarked within ``findings``.""" + if not user.is_authenticated: + return set() + return set( + FindingBookmark.objects.filter( + user=user, finding_id__in=findings.values("id"), + ).values_list("finding_id", flat=True), + ) + + +def _bookmark_count(user) -> int: + if not user.is_authenticated: + return 0 + return FindingBookmark.objects.filter(user=user).count() + + +def _queue_counters(user) -> dict: + """Mirror of ``QueueCountersViewSet.list`` — counts only.""" + qs = _user_findings(user) + open_qs = _open_findings(qs) + critical = open_qs.filter(severity="Critical").count() + high = open_qs.filter(severity="High").count() + try: + sla_breach = open_qs.filter( + sla_expiration_date__isnull=False, + sla_expiration_date__lt=timezone.now().date(), + ).count() + except Exception: + sla_breach = 0 + week_ago = timezone.now() - timedelta(days=7) + fixed_this_week = qs.filter(mitigated__gte=week_ago).count() + return { + "critical": critical, + "high": high, + "sla_breach": sla_breach, + "fixed_this_week": fixed_this_week, + } + + +def _category_counts(user) -> list[dict]: + """Mirror of ``CategoryCountsViewSet.list`` — list of {id, count} entries.""" + qs = _user_findings(user) + open_qs = _open_findings(qs) + annotated = categorize_queryset(open_qs) + rows = ( + annotated.values("category") + .order_by() + .annotate(count=Count("id", distinct=True)) + ) + by_category = {r["category"]: r["count"] for r in rows} + return [ + {"id": cid, "count": int(by_category.get(cid, 0))} + for cid in CATEGORY_IDS + ] + + +def _common_context(request) -> dict: + """Context shared by every watercolor screen (top nav, badges).""" + user = request.user + display_name = ( + getattr(user, "get_full_name", lambda: "")() or getattr(user, "username", "") + ) + return { + "wc_user_display_name": display_name or "Analyst", + "wc_user_initials": _initials(display_name or getattr(user, "username", "A")), + "wc_user_role": "Security Analyst", + "bookmark_count": _bookmark_count(user), + "wc_category_ids": CATEGORY_IDS, + } + + +def _initials(name: str) -> str: + parts = [p for p in name.split() if p] + if not parts: + return "?" + if len(parts) == 1: + return parts[0][:2].upper() + return (parts[0][:1] + parts[-1][:1]).upper() + + +def home(request): + """Home screen — hero counters, category tiles, three card rails.""" + user = request.user + findings = get_authorized_findings(Permissions.Finding_View, user=user) + open_qs = _open_findings(findings) + + counters = _queue_counters(user) + category_counts = _category_counts(user) + + # Picked for you = assigned-to-me, newest first. + picked = list( + open_qs.filter(reviewers=user) + .select_related("test", "test__test_type") + .prefetch_related("tags") + .order_by("-created")[:4], + ) + + fresh = list( + open_qs.select_related("test", "test__test_type") + .prefetch_related("tags") + .order_by("-created")[:4], + ) + + week_ago = timezone.now() - timedelta(days=7) + trending = list( + open_qs.filter(created__gte=week_ago) + .select_related("test", "test__test_type") + .prefetch_related("tags") + .order_by("-cvssv3_score", "-created")[:4], + ) + + all_cards = list({f.id: f for f in (picked + fresh + trending)}.values()) + bookmarked_ids = _bookmarked_ids_for( + user, + findings.filter(id__in=[f.id for f in all_cards]), + ) + + ctx = _common_context(request) + ctx.update({ + "counters": counters, + "category_counts": category_counts, + "picked": picked, + "fresh": fresh, + "trending": trending, + "bookmarked_ids": bookmarked_ids, + "active_screen": "home", + }) + return render(request, "dojo/watercolor/home.html", ctx) + + +def _apply_browse_filters(qs, params): + """Apply querystring filters to the base authorized finding queryset.""" + severity = params.getlist("severity") + if severity: + qs = qs.filter(severity__in=severity) + + scanner = params.getlist("scanner") + if scanner: + qs = qs.filter(test__scan_type__in=scanner) + + category = params.getlist("category") + if category: + annotated = categorize_queryset(qs) + qs = annotated.filter(category__in=category) + + age = params.get("age") + if age in AGE_BUCKETS: + cutoff = timezone.now() - timedelta(days=AGE_BUCKETS[age]) + qs = qs.filter(created__gte=cutoff) + + min_cvss = params.get("min_cvss") + if min_cvss in {"7", "9"}: + qs = qs.filter(cvssv3_score__gte=float(min_cvss)) + + return qs + + +def _apply_browse_ordering(qs, order_by): + if order_by == "newest": + return qs.order_by("-created") + if order_by == "oldest": + return qs.order_by("created") + # default: severity + return qs.order_by("numerical_severity", "-cvssv3_score", "-created") + + +def browse(request): + """Browse screen — filter rail + paginated results grid.""" + user = request.user + findings = get_authorized_findings(Permissions.Finding_View, user=user) + open_qs = _open_findings(findings) + filtered = _apply_browse_filters(open_qs, request.GET) + order_by = request.GET.get("order_by", "severity") + ordered = _apply_browse_ordering(filtered, order_by).distinct() + + paginator = Paginator( + ordered.select_related("test", "test__test_type").prefetch_related("tags"), + BROWSE_PAGE_SIZE, + ) + page_number = request.GET.get("page") or 1 + page = paginator.get_page(page_number) + + bookmarked_ids = _bookmarked_ids_for(user, findings.filter(id__in=[f.id for f in page.object_list])) + + active_filters = [] + for key in ("severity", "scanner", "category"): + active_filters.extend( + {"key": key, "value": val} for val in request.GET.getlist(key) + ) + if request.GET.get("age") in AGE_BUCKETS: + active_filters.append({"key": "age", "value": request.GET["age"]}) + if request.GET.get("min_cvss") in {"7", "9"}: + active_filters.append({"key": "min_cvss", "value": request.GET["min_cvss"]}) + + ctx = _common_context(request) + ctx.update({ + "page_obj": page, + "paginator": paginator, + "total_count": paginator.count, + "bookmarked_ids": bookmarked_ids, + "selected_severity": request.GET.getlist("severity"), + "selected_scanner": request.GET.getlist("scanner"), + "selected_category": request.GET.getlist("category"), + "selected_age": request.GET.get("age", "all"), + "selected_min_cvss": request.GET.get("min_cvss", "any"), + "order_by": order_by, + "active_filters": active_filters, + "active_screen": "browse", + }) + return render(request, "dojo/watercolor/browse.html", ctx) + + +def queue(request): + """Queue screen — union of (bookmarked OR reviewer-assigned).""" + user = request.user + findings = get_authorized_findings(Permissions.Finding_View, user=user) + + bookmarked_ids = set( + FindingBookmark.objects.filter( + user=user, finding_id__in=findings.values("id"), + ).values_list("finding_id", flat=True), + ) + assigned_ids = set( + findings.filter(reviewers=user).values_list("id", flat=True), + ) + union_ids = bookmarked_ids | assigned_ids + + union_qs = ( + findings.filter(id__in=union_ids) + .select_related("test", "test__test_type") + .prefetch_related("tags") + .order_by("numerical_severity", "-cvssv3_score", "-created") + .distinct() + ) + items = list(union_qs) + + ctx = _common_context(request) + ctx.update({ + "items": items, + "total_count": len(items), + "bookmarked_ids": bookmarked_ids, + "empty_copy": QUEUE_EMPTY_COPY, + "active_screen": "queue", + }) + return render(request, "dojo/watercolor/queue.html", ctx) + + +REPORT_CARDS = ( + { + "id": "executive", + "wash": "sky", + "title": "Executive summary", + "category": "Leadership briefing", + "body": "High-level snapshot of severity mix, SLA breach, and remediation velocity.", + }, + { + "id": "compliance", + "wash": "mint", + "title": "Compliance posture", + "category": "Regulatory", + "body": "Findings rolled up against your active compliance frameworks.", + }, + { + "id": "engineering", + "wash": "lilac", + "title": "Engineering backlog", + "category": "Triage handoff", + "body": "Open findings grouped by product, assignee, and CWE.", + }, + { + "id": "scanner", + "wash": "rose", + "title": "Scanner coverage", + "category": "Tool health", + "body": "What each scanner found this period and where coverage thinned out.", + }, +) + + +def reports(request): + """Reports screen — four discoverability cards linking to the report builder.""" + ctx = _common_context(request) + ctx.update({ + "report_cards": REPORT_CARDS, + "active_screen": "reports", + }) + return render(request, "dojo/watercolor/reports.html", ctx) + + +def drawer(request, finding_id: int): + """Slide-in finding detail partial; 404 if the user can't access the finding.""" + user = request.user + findings = get_authorized_findings(Permissions.Finding_View, user=user) + finding = get_object_or_404( + findings.select_related("test", "test__engagement", "test__engagement__product") + .prefetch_related("tags", "reviewers"), + pk=finding_id, + ) + bookmarked = FindingBookmark.objects.filter(user=user, finding=finding).exists() + ctx = { + "finding": finding, + "bookmarked": bookmarked, + "product": getattr(getattr(finding.test, "engagement", None), "product", None), + } + return render(request, "dojo/watercolor/_partials/_drawer.html", ctx) diff --git a/unittests/test_watercolor_views.py b/unittests/test_watercolor_views.py new file mode 100644 index 00000000000..f8615af30ca --- /dev/null +++ b/unittests/test_watercolor_views.py @@ -0,0 +1,222 @@ +# Copyright (c) Tactivos / Mural. Licensed under BSD-3-Clause (see LICENSE). +""" +Tests for the watercolor FE SSR views (/watercolor/*). + +These exercise: + +* anonymous redirect to login on every screen route +* authenticated 200 on every screen route +* the verbatim Queue empty-state copy +* the Queue union (bookmarked OR reviewer-assigned) +* permission isolation across two analysts in disjoint products +* the Drawer 200 + 404 contract +* querystring filters on Browse +""" +from __future__ import annotations + +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import reverse +from django.utils import timezone + +from dojo.models import ( + CategoryMapping, + Engagement, + Finding, + FindingBookmark, + Product, + Product_Member, + Product_Type, + Role, + Test, + Test_Type, +) +from dojo.watercolor.views import QUEUE_EMPTY_COPY + +User = get_user_model() + + +def _build_product(name="P", prod_type=None): + if prod_type is None: + prod_type = Product_Type.objects.create(name=f"{name}-type") + return Product.objects.create(name=name, prod_type=prod_type, description=name) + + +def _build_test(product, scan_type="Generic"): + tt, _ = Test_Type.objects.get_or_create(name=scan_type) + eng = Engagement.objects.create( + product=product, + name=f"E-{product.id}", + target_start=timezone.now().date(), + target_end=timezone.now().date() + timedelta(days=1), + ) + return Test.objects.create( + engagement=eng, + test_type=tt, + scan_type=scan_type, + target_start=timezone.now(), + target_end=timezone.now() + timedelta(days=1), + ) + + +def _build_finding(test, *, cwe=None, severity="High", active=True): + return Finding.objects.create( + title="t", + test=test, + cwe=cwe or 0, + severity=severity, + active=active, + verified=False, + ) + + +SCREEN_ROUTES = ( + "watercolor_home", + "watercolor_browse", + "watercolor_queue", + "watercolor_reports", +) + + +class WatercolorAnonAccessTests(TestCase): + + """Anonymous requests on every screen route should not return 200 content.""" + + def test_anonymous_redirects_to_login(self): + client = Client() + for name in SCREEN_ROUTES: + r = client.get(reverse(name)) + self.assertIn( + r.status_code, {302, 301, 403}, + f"{name} should not be reachable anonymously (got {r.status_code})", + ) + + +class WatercolorAuthScreenTests(TestCase): + + """Authenticated user can load all four screens.""" + + def setUp(self): + self.user = User.objects.create_user(username="wc-user", password="x") # noqa: S106 + self.user.is_staff = True + self.user.is_superuser = True # mirrors test_dashboard_aggregations + self.user.save() + self.client = Client() + self.client.force_login(self.user) + CategoryMapping.objects.create(cwe=79, category="web") + self.product = _build_product("wc") + self.test_obj = _build_test(self.product) + + def test_authenticated_user_gets_200(self): + for name in SCREEN_ROUTES: + r = self.client.get(reverse(name)) + self.assertEqual( + r.status_code, 200, + f"{name} should return 200 for authenticated user, got {r.status_code}", + ) + + def test_queue_empty_state_copy(self): + Finding.objects.all().delete() + FindingBookmark.objects.all().delete() + r = self.client.get(reverse("watercolor_queue")) + self.assertEqual(r.status_code, 200) + self.assertIn(QUEUE_EMPTY_COPY, r.content.decode("utf-8")) + + def test_queue_union(self): + f_book = _build_finding(self.test_obj, cwe=79, severity="High") + f_assigned = _build_finding(self.test_obj, cwe=79, severity="Medium") + FindingBookmark.objects.create(user=self.user, finding=f_book) + f_assigned.reviewers.add(self.user) + + r = self.client.get(reverse("watercolor_queue")) + self.assertEqual(r.status_code, 200) + body = r.content.decode("utf-8") + self.assertEqual(body.count(f'data-finding-id="{f_book.id}"'), 1) + self.assertEqual(body.count(f'data-finding-id="{f_assigned.id}"'), 1) + + def test_browse_querystring_filters(self): + f_web_high = _build_finding(self.test_obj, cwe=79, severity="High") + f_low = _build_finding(self.test_obj, cwe=79, severity="Low") + r = self.client.get(reverse("watercolor_browse"), { + "severity": "High", + "category": "web", + }) + self.assertEqual(r.status_code, 200) + body = r.content.decode("utf-8") + self.assertIn(f'data-finding-id="{f_web_high.id}"', body) + self.assertNotIn(f'data-finding-id="{f_low.id}"', body) + + def test_drawer_accessible_finding_returns_partial(self): + finding = _build_finding(self.test_obj, cwe=79, severity="High") + r = self.client.get( + reverse("watercolor_drawer", args=[finding.id]), + ) + self.assertEqual(r.status_code, 200) + self.assertIn("wc-drawer__head", r.content.decode("utf-8")) + + +class WatercolorDrawerInaccessibleTests(TestCase): + + """Non-superuser cannot see a finding in another product — drawer 404s.""" + + def setUp(self): + CategoryMapping.objects.create(cwe=79, category="web") + self.pt = Product_Type.objects.create(name="iso-d") + self.prod_a = Product.objects.create(name="A", prod_type=self.pt, description="A") + self.prod_b = Product.objects.create(name="B", prod_type=self.pt, description="B") + self.user_a = User.objects.create_user(username="alice-d", password="x") # noqa: S106 + role = Role.objects.filter(name__iexact="Reader").first() or Role.objects.first() + if role is not None: + Product_Member.objects.create(product=self.prod_a, user=self.user_a, role=role) + self.test_b = _build_test(self.prod_b) + self.f_b = _build_finding(self.test_b, cwe=79, severity="High") + self.client = Client() + self.client.force_login(self.user_a) + + def test_drawer_inaccessible_finding_returns_404(self): + r = self.client.get(reverse("watercolor_drawer", args=[self.f_b.id])) + # 404 (not 403) so existence does not leak. + self.assertEqual(r.status_code, 404) + + +class WatercolorPermissionIsolationTests(TestCase): + + """Two analysts in disjoint products must see disjoint Home / Queue content.""" + + def setUp(self): + CategoryMapping.objects.create(cwe=79, category="web") + self.pt = Product_Type.objects.create(name="iso-w") + self.prod_a = Product.objects.create(name="A", prod_type=self.pt, description="A") + self.prod_b = Product.objects.create(name="B", prod_type=self.pt, description="B") + self.user_a = User.objects.create_user(username="alice-w", password="x") # noqa: S106 + self.user_b = User.objects.create_user(username="bob-w", password="x") # noqa: S106 + role = Role.objects.filter(name__iexact="Reader").first() or Role.objects.first() + if role is not None: + Product_Member.objects.create(product=self.prod_a, user=self.user_a, role=role) + Product_Member.objects.create(product=self.prod_b, user=self.user_b, role=role) + self.test_a = _build_test(self.prod_a) + self.test_b = _build_test(self.prod_b) + self.f_a = _build_finding(self.test_a, cwe=79, severity="Critical") + self.f_b = _build_finding(self.test_b, cwe=79, severity="Critical") + # Both users bookmark "their" finding. + FindingBookmark.objects.create(user=self.user_a, finding=self.f_a) + FindingBookmark.objects.create(user=self.user_b, finding=self.f_b) + + def test_permission_isolation(self): + ca = Client() + ca.force_login(self.user_a) + cb = Client() + cb.force_login(self.user_b) + ra_home = ca.get(reverse("watercolor_home")) + rb_home = cb.get(reverse("watercolor_home")) + ra_queue = ca.get(reverse("watercolor_queue")) + rb_queue = cb.get(reverse("watercolor_queue")) + for r in (ra_home, rb_home, ra_queue, rb_queue): + self.assertEqual(r.status_code, 200) + body_a = ra_queue.content.decode("utf-8") + body_b = rb_queue.content.decode("utf-8") + # User A cannot see B's finding card and vice-versa. + self.assertNotIn(f'data-finding-id="{self.f_b.id}"', body_a) + self.assertNotIn(f'data-finding-id="{self.f_a.id}"', body_b)