diff --git a/backend/internal/domain/vulnerability.go b/backend/internal/domain/vulnerability.go index f1ad8f83..3206471a 100644 --- a/backend/internal/domain/vulnerability.go +++ b/backend/internal/domain/vulnerability.go @@ -203,12 +203,16 @@ func (v *Vulnerability) DedupKey() string { // VulnStats is the aggregate posture returned by GET /vulnerabilities/stats. type VulnStats struct { - Total int64 `json:"total"` - Open int64 `json:"open"` - BySeverity map[string]int64 `json:"by_severity"` - ByStatus map[string]int64 `json:"by_status"` - BySource map[string]int64 `json:"by_source"` - ByTier map[string]int64 `json:"by_tier"` - KEVCount int64 `json:"kev_count"` - ExploitCount int64 `json:"exploit_count"` + Total int64 `json:"total"` + Open int64 `json:"open"` + BySeverity map[string]int64 `json:"by_severity"` + ByStatus map[string]int64 `json:"by_status"` + BySource map[string]int64 `json:"by_source"` + ByTier map[string]int64 `json:"by_tier"` + KEVCount int64 `json:"kev_count"` + // KEVOpen counts KEV-listed vulnerabilities still waiting for a fix (open, + // triaged or in remediation). KEVCount includes remediated and accepted ones, + // so it cannot answer "how many exploited flaws are still exposed". + KEVOpen int64 `json:"kev_open"` + ExploitCount int64 `json:"exploit_count"` } diff --git a/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go b/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go index 02543e07..cd951fed 100644 --- a/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go +++ b/backend/internal/infrastructure/repository/gorm_vulnerability_repository.go @@ -247,6 +247,9 @@ func (r *GormVulnerabilityRepository) Stats(ctx context.Context, tenantID uuid.U } base().Where("status = ?", domain.VulnStatusOpen).Count(&stats.Open) base().Where("kev = ?", true).Count(&stats.KEVCount) + base().Where("kev = ? AND status IN ?", true, []domain.VulnStatus{ + domain.VulnStatusOpen, domain.VulnStatusTriaged, domain.VulnStatusInRemediation, + }).Count(&stats.KEVOpen) base().Where("exploit_available = ?", true).Count(&stats.ExploitCount) type kv struct { diff --git a/backend/internal/infrastructure/repository/gorm_vulnerability_stats_test.go b/backend/internal/infrastructure/repository/gorm_vulnerability_stats_test.go new file mode 100644 index 00000000..5d8fb49e --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_vulnerability_stats_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package repository + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/opendefender/openrisk/internal/domain" + "github.com/opendefender/openrisk/internal/testsupport/sqliteschema" +) + +func setupVulnStatsRepo(t *testing.T) (*GormVulnerabilityRepository, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + require.NoError(t, err) + require.NoError(t, db.Exec(`CREATE TABLE vulnerabilities (id TEXT PRIMARY KEY)`).Error) + require.NoError(t, sqliteschema.Reconcile(db, "vulnerabilities", &domain.Vulnerability{})) + return NewGormVulnerabilityRepository(db), db +} + +func seedStatsVuln(t *testing.T, db *gorm.DB, tenant uuid.UUID, kev bool, status domain.VulnStatus) { + t.Helper() + v := domain.Vulnerability{ + ID: uuid.New(), + TenantID: tenant, + CVEID: "CVE-2026-" + uuid.NewString()[:5], + Title: "test finding", + Severity: domain.VulnSeverity("high"), + Status: status, + KEV: kev, + } + require.NoError(t, db.Create(&v).Error) +} + +// The sidebar badge and the dashboard counter both read kev_open: a KEV flaw is +// counted while it still waits for a fix, and drops out once remediated, +// accepted or dismissed. +func TestVulnStats_KEVOpen_Success(t *testing.T) { + repo, db := setupVulnStatsRepo(t) + tenant := uuid.New() + seedStatsVuln(t, db, tenant, true, domain.VulnStatusOpen) + seedStatsVuln(t, db, tenant, true, domain.VulnStatusTriaged) + seedStatsVuln(t, db, tenant, true, domain.VulnStatusInRemediation) + seedStatsVuln(t, db, tenant, true, domain.VulnStatusRemediated) + seedStatsVuln(t, db, tenant, true, domain.VulnStatusAccepted) + seedStatsVuln(t, db, tenant, true, domain.VulnStatusFalsePositive) + seedStatsVuln(t, db, tenant, false, domain.VulnStatusOpen) + + stats, err := repo.Stats(context.Background(), tenant) + require.NoError(t, err) + assert.Equal(t, int64(6), stats.KEVCount, "kev_count keeps its historical meaning") + assert.Equal(t, int64(3), stats.KEVOpen) +} + +// A tenant with no rows gets zeroes, not an error. +func TestVulnStats_KEVOpen_NotFound(t *testing.T) { + repo, _ := setupVulnStatsRepo(t) + stats, err := repo.Stats(context.Background(), uuid.New()) + require.NoError(t, err) + assert.Equal(t, int64(0), stats.KEVOpen) +} + +// Another tenant's open KEV flaws never reach this tenant's count. +func TestVulnStats_KEVOpen_Unauthorized(t *testing.T) { + repo, db := setupVulnStatsRepo(t) + mine, theirs := uuid.New(), uuid.New() + seedStatsVuln(t, db, theirs, true, domain.VulnStatusOpen) + seedStatsVuln(t, db, theirs, true, domain.VulnStatusOpen) + seedStatsVuln(t, db, mine, true, domain.VulnStatusOpen) + + stats, err := repo.Stats(context.Background(), mine) + require.NoError(t, err) + assert.Equal(t, int64(1), stats.KEVOpen) +} diff --git a/frontend/.lint-ceiling.json b/frontend/.lint-ceiling.json index 7114491f..05c13880 100644 --- a/frontend/.lint-ceiling.json +++ b/frontend/.lint-ceiling.json @@ -1,11 +1,11 @@ { - "total": 99, + "total": 97, "rules": { "@typescript-eslint/no-unused-vars": 27, "@typescript-eslint/no-explicit-any": 21, - "react-hooks/set-state-in-effect": 13, "react-hooks/preserve-manual-memoization": 12, - "react-refresh/only-export-components": 10, + "react-hooks/set-state-in-effect": 12, + "react-refresh/only-export-components": 9, "react-hooks/static-components": 6, "react-hooks/purity": 3, "prefer-const": 2, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0d2eb3a..918613ff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -397,6 +397,21 @@ const DashboardLayout = () => { }; }, []); + // ⌘K / Ctrl+K opens the palette from anywhere. The palette is mounted only + // once opened, so its own listener cannot be the one that opens it: before + // #900 the shortcut did nothing until the search field had been clicked once. + // While it is open, the palette's listener handles the toggle. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return; + if (useUIStore.getState().cmdkOpen) return; + e.preventDefault(); + setCmdkOpen(true); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [setCmdkOpen]); + // Discoverable global shortcuts (UX-26). Rows shown in ShortcutsOverlay must // mirror these handlers. The hook ignores keys while typing / with ⌘/Ctrl/Alt. useHotkeys([ @@ -442,10 +457,7 @@ const DashboardLayout = () => { {/* The organization's accent, worn by every member (#718). */} setMobileNavOpen(false)} /> -
+
{/* Demo (permanent, not dismissible by design — see shared/DemoBanner) on top; Offline below it, closest to the header it pushes. */} diff --git a/frontend/src/components/layout/AppHeader.tsx b/frontend/src/components/layout/AppHeader.tsx index 24d50bce..c77c570d 100644 --- a/frontend/src/components/layout/AppHeader.tsx +++ b/frontend/src/components/layout/AppHeader.tsx @@ -1,21 +1,24 @@ // Copyright (c) 2026 OpenDefender Contributors // SPDX-License-Identifier: AGPL-3.0-only // -// Global glass header (OpenRisk.dc.html §5): breadcrumb · ⌘K search · connection -// status · language · density · shortcuts · notifications · theme. Sticky, -// backdrop-blurred, sits above the scrollable body. On < lg it collapses to a -// hamburger + brand. +// Global header (October 2026 redesign, #900): breadcrumb · ⌘K search · +// connection status · language · density · theme · notifications · help. It +// keeps the redesign's layout (search, theme, bell, help on a solid canvas) and +// adds the controls the redesign does not draw but the product relies on: the +// live connection dot, the language switch and table density. On < lg it +// collapses to a hamburger. // // Two controls were removed rather than kept as decoration (docs/ui/dead-controls.md): // the "Voice assistant" microphone (no speech feature exists anywhere in the -// product) and the panel footer's "View all notifications" (there is no -// all-notifications view; it merely closed the panel). The pulsing green dot, +// product) and the old "View all notifications" footer, which merely closed the +// panel. The redesign's footer link goes somewhere real, the activity feed, so +// it is back with that destination. The pulsing green dot, // which claimed "Realtime" on every tenant regardless of anything, is now a real // connection indicator driven by lib/connection. import { useSyncExternalStore, useState, useEffect, useId, useRef } from 'react'; -import { useNavigate } from 'react-router'; -import { Search, Bell, Sun, Moon, Menu, Rows2, Rows3, Rows4, Keyboard } from 'lucide-react'; +import { Link, useNavigate } from 'react-router'; +import { Search, Bell, Sun, Moon, Menu, Rows2, Rows3, Rows4, CircleHelp } from 'lucide-react'; import { cn, useDismissableLayer, useExitTimer } from '../../shared/ds'; import { useUIStore } from '../../store/uiStore'; import { useUIStrings } from '../../shared/uiStrings'; @@ -53,7 +56,7 @@ interface AppHeaderProps { } const iconBtn = - 'w-9 h-9 rounded-[9px] flex items-center justify-center text-ink-muted hover:bg-hover hover:text-ink transition-colors'; + 'w-[34px] h-[34px] rounded-[10px] flex items-center justify-center text-fg-secondary hover:bg-surface-3 hover:text-ink transition-colors'; export const AppHeader = ({ onOpenMobileNav }: AppHeaderProps) => { const setCmdkOpen = useUIStore((s) => s.setCmdkOpen); @@ -92,7 +95,7 @@ export const AppHeader = ({ onOpenMobileNav }: AppHeaderProps) => { const badgeArmed = useArmedBadge(unreadFetched); return ( -
+
{/* Mobile hamburger */} -
- {/* Actions */} -
+
- {/* Replays the 3-coach-mark product tour (spec §8). A tour you cannot - replay is a tour you have to get right first time — and that nobody - can consult when they actually have the question. */} - - - + {/* Both icons stacked in one grid cell and cross-faded on + --motion-hover (opacity + a slight scale) — no rotation, no pop. */} + + + {/* Notifications */}
{notifMounted && setNotifOpen(false)} />}
+ {/* Help: the keyboard shortcuts sheet ("?" opens it from anywhere). */}
@@ -269,11 +258,11 @@ function NotifBadge({ count, armed }: { count: number; armed: boolean }) { aria-hidden="true" data-armed={armed} data-visible={visible} - className="notif-badge absolute top-[4px] right-[4px] min-w-[16px] h-[16px] px-1 rounded-full flex items-center justify-center text-[11px] font-semibold tabular-nums" + className="notif-badge absolute top-[5px] right-[5px] min-w-[15px] h-[15px] px-1 rounded-full flex items-center justify-center text-[9.5px] font-bold tabular-nums" style={{ - background: 'var(--accent-solid)', + background: 'var(--danger-solid)', color: 'var(--fg-on-solid)', - boxShadow: '0 0 0 2px var(--glass)', + boxShadow: '0 0 0 2px var(--surface-0)', }} > {count > 9 ? '9+' : count} @@ -324,7 +313,7 @@ function useEnterGate(open: boolean): boolean { return ready && open; } -/* ---------- Notifications panel (glass, anchored right) ---------- */ +/* ---------- Notifications panel (anchored right) ---------- */ // Reads the real /notifications feed. This panel used to render four invented // notifications on every tenant, which is how a fresh install came to report // incidents it had never had. @@ -396,16 +385,17 @@ function NotifPanel({ open, onClose }: { open: boolean; onClose: () => void }) { aria-modal="true" aria-labelledby={titleId} tabIndex={-1} - className="notif-panel glass-strong absolute top-[44px] right-0 w-[352px] rounded-[16px] overflow-hidden shadow-card-lg z-70 outline-none" + className="notif-panel absolute top-[42px] right-0 w-[400px] max-w-[calc(100vw-24px)] rounded-[14px] overflow-hidden bg-surface-2 border border-border-default z-70 outline-none" + style={{ boxShadow: 'var(--elev-3)' }} > -
- +
+ {L.notifTitle} {items.some((it) => it.unread) && ( @@ -415,7 +405,7 @@ function NotifPanel({ open, onClose }: { open: boolean; onClose: () => void }) { {/* category filter — separate the contexts (UX-6). Only rendered when there is something to filter; chips over an empty list are noise. */} {cats.length > 1 && ( -
+
{cats.map((c) => { const active = filter === c; const label = @@ -437,7 +427,7 @@ function NotifPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
)} -
+
{isLoading ? (
@@ -487,26 +477,28 @@ function NotifPanel({ open, onClose }: { open: boolean; onClose: () => void }) { openItem(it); } }} - className="w-full text-left flex gap-3 px-[17px] py-[13px] border-b border-border cursor-pointer hover:bg-hover focus-visible:bg-hover outline-none transition-colors" - style={{ - background: it.unread - ? 'color-mix(in srgb,var(--accent) 4%,transparent)' - : 'transparent', - }} + className="w-full text-left flex gap-3 px-3.5 py-3 border-b border-border-subtle cursor-pointer hover:bg-surface-3 focus-visible:bg-surface-3 outline-none transition-colors" >
- +
-
{it.title}
+
+ {it.title} +
{it.body && ( -
{it.body}
+
{it.body}
)}
{it.time} @@ -532,6 +524,13 @@ function NotifPanel({ open, onClose }: { open: boolean; onClose: () => void }) { }) )}
+ + {L.notifSeeAll} +
); diff --git a/frontend/src/components/layout/CommandPalette.tsx b/frontend/src/components/layout/CommandPalette.tsx index 0f865647..a1b80c8d 100644 --- a/frontend/src/components/layout/CommandPalette.tsx +++ b/frontend/src/components/layout/CommandPalette.tsx @@ -1,9 +1,10 @@ // Copyright (c) 2026 OpenDefender Contributors // SPDX-License-Identifier: AGPL-3.0-only // -// ⌘K / Ctrl+K command palette (OpenRisk.dc.html §8). Glass overlay, autofocused -// input, grouped Navigation + Quick actions filtered as you type. Esc / backdrop -// closes. Registers the global ⌘K keyboard shortcut itself. +// ⌘K / Ctrl+K command palette, laid out as in the October 2026 redesign (#900): +// actions first, then pages ("Aller à"), then the live cross-entity results. +// ↑↓ move the highlighted row, ↵ runs it, Esc or the backdrop closes. Registers +// the global ⌘K keyboard shortcut itself. import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; @@ -26,6 +27,7 @@ import { } from 'lucide-react'; import { useUIStore } from '../../store/uiStore'; import { useUIStrings } from '../../shared/uiStrings'; +import { pickLocalized } from '../../i18n/locales'; import { visibleNavGroups } from '../../shared/navModel'; import { usePermissions } from '../../hooks/usePermissions'; import { @@ -38,7 +40,9 @@ interface CmdItem { label: string; icon: LucideIcon; shortcut?: string; - subtitle?: string; + /** Right-hand hint: the nav group of a page, an entity's reference. */ + hint?: string; + hintMono?: boolean; badge?: { text: string; tone: string }; run: () => void; } @@ -58,6 +62,22 @@ const TYPE_ICON: Record = { cve: Globe, user: Users, }; +// The search API sends raw keys; the palette shows words. +const SEVERITY_LABEL: Record = { + critical: { fr: 'Critique', en: 'Critical' }, + high: { fr: 'Élevé', en: 'High' }, + medium: { fr: 'Moyen', en: 'Medium' }, + low: { fr: 'Faible', en: 'Low' }, + info: { fr: 'Info', en: 'Info' }, +}; +const STATUS_LABEL: Record = { + open: { fr: 'Ouvert', en: 'Open' }, + in_progress: { fr: 'En cours', en: 'In progress' }, + mitigated: { fr: 'Atténué', en: 'Mitigated' }, + accepted: { fr: 'Accepté', en: 'Accepted' }, + closed: { fr: 'Clos', en: 'Closed' }, +}; + const TONE: Record = { critical: 'var(--critical)', high: 'var(--high)', @@ -80,7 +100,9 @@ export const CommandPalette = () => { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); + const [active, setActive] = useState(0); const inputRef = useRef(null); + const listRef = useRef(null); // Global ⌘K / Ctrl+K + Esc. useEffect(() => { @@ -99,6 +121,7 @@ export const CommandPalette = () => { if (open) { setQuery(''); setResults([]); + setActive(0); // Autofocus after the mount animation begins. requestAnimationFrame(() => inputRef.current?.focus()); } @@ -135,6 +158,7 @@ export const CommandPalette = () => { g.items.map((it) => ({ label: L[it.labelKey], icon: it.icon, + hint: it.pinned ? undefined : L[g.groupKey], run: () => { navigate(it.href ?? it.path); close(); @@ -165,14 +189,7 @@ export const CommandPalette = () => { }, }, { - label: - lang === 'fr' - ? theme === 'dark' - ? 'Thème clair' - : 'Thème sombre' - : theme === 'dark' - ? 'Light theme' - : 'Dark theme', + label: theme === 'dark' ? L.themeToLight : L.themeToDark, icon: theme === 'dark' ? Sun : Moon, run: () => { toggleTheme(); @@ -195,94 +212,130 @@ export const CommandPalette = () => { const resultItems: CmdItem[] = results.map((r) => ({ label: r.title, icon: TYPE_ICON[r.type] ?? Search, - subtitle: r.subtitle, - badge: r.badge ? { text: r.badge, tone: TONE[r.badge] ?? 'var(--fg-muted)' } : undefined, + hint: + r.type === 'risk' && r.subtitle && STATUS_LABEL[r.subtitle] + ? pickLocalized(lang, STATUS_LABEL[r.subtitle]) + : r.subtitle, + badge: r.badge + ? { + text: (SEVERITY_LABEL[r.badge] && pickLocalized(lang, SEVERITY_LABEL[r.badge])) ?? r.badge, + tone: TONE[r.badge] ?? 'var(--fg-muted)', + } + : undefined, run: () => { navigate(r.url); close(); }, })); return [ - { label: lang === 'fr' ? 'Résultats' : 'Results', items: resultItems }, - { label: lang === 'fr' ? 'Navigation' : 'Navigation', items: flt(nav) }, - { label: lang === 'fr' ? 'Actions rapides' : 'Quick actions', items: flt(actions) }, + { label: L.cmdActions, items: flt(actions) }, + { + label: q ? L.cmdPages : L.cmdGoTo, + // With no query the palette offers a short jump list, as the design does. + items: q ? flt(nav) : nav.slice(0, 8), + }, + { label: L.cmdResults, items: resultItems }, ].filter((g) => g.items.length > 0); }, [L, query, results, navigate, setOpen, theme, lang, toggleTheme, toggleLang, can, isAdmin]); + const flat = groups.flatMap((g) => g.items); + const current = Math.min(active, Math.max(flat.length - 1, 0)); + if (!open) return null; - const runFirst = () => { - const first = groups[0]?.items[0]; - first?.run(); + const onInputKey = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + if (flat.length === 0) return; + const next = + e.key === 'ArrowDown' ? (current + 1) % flat.length : (current - 1 + flat.length) % flat.length; + setActive(next); + listRef.current + ?.querySelector(`[data-cmd-index="${next}"]`) + ?.scrollIntoView({ block: 'nearest' }); + } else if (e.key === 'Enter') { + e.preventDefault(); + flat[current]?.run(); + } }; + let index = -1; return (
setOpen(false)} - className="fixed inset-0 z-80 flex items-start justify-center" + className="fixed inset-0 z-80 flex items-start justify-center px-4" style={{ background: 'var(--surface-overlay)', - backdropFilter: 'blur(6px)', - WebkitBackdropFilter: 'blur(6px)', - paddingTop: '14vh', + backdropFilter: 'blur(4px)', + WebkitBackdropFilter: 'blur(4px)', + paddingTop: '12vh', animation: 'or-fadein var(--motion-enter)', }} >
e.stopPropagation()} - className="glass-strong rounded-[18px] overflow-hidden shadow-card-lg" - style={{ width: 'min(92vw,600px)', animation: 'or-scalein var(--motion-enter)' }} + className="w-[640px] max-w-full rounded-[14px] overflow-hidden bg-surface-2 border border-border-default" + style={{ boxShadow: 'var(--elev-3)', animation: 'or-scalein var(--motion-enter)' }} > -
+
{searching ? ( - + ) : ( - + )} setQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && runFirst()} + onChange={(e) => { + setQuery(e.target.value); + setActive(0); + }} + onKeyDown={onInputKey} placeholder={L.cmdkPlaceholder} - className="flex-1 bg-transparent border-none outline-none text-ink text-[15px] placeholder:text-ink-muted" + aria-label={L.cmdkPlaceholder} + className="flex-1 h-full bg-transparent border-none outline-none focus-visible:outline-none text-ink text-[15px] placeholder:text-ink-muted" /> - - ESC + + {L.cmdEsc}
-
+
{groups.map((g) => ( -
-
+
+
{g.label}
- {g.items.map((it, i) => { + {g.items.map((it) => { const Icon = it.icon; + index += 1; + const i = index; + const on = i === current; return ( ); })}
))} {groups.length === 0 && ( -
- {searching ? (lang === 'fr' ? 'Recherche…' : 'Searching…') : L.notifEmpty} +
+ {searching ? L.cmdSearching : `${L.cmdNoResults} « ${query.trim()} ».`}
)}
-
- ↑↓ {L.navigate} · ↵ {L.open} · esc {L.close} +
+ ↑↓ {L.navigate} + ↵ {L.open} + {can('risks:create') && ( + N {L.cmdNewRiskHint} + )}
diff --git a/frontend/src/components/layout/OrgPlanLabel.tsx b/frontend/src/components/layout/OrgPlanLabel.tsx index 1c48fa8d..d486aded 100644 --- a/frontend/src/components/layout/OrgPlanLabel.tsx +++ b/frontend/src/components/layout/OrgPlanLabel.tsx @@ -43,7 +43,7 @@ export function OrgPlanLabel() { if (!name) return null; return ( -
+
{t('common.planName', { plan: name })}
); diff --git a/frontend/src/components/layout/OrgSwitcher.tsx b/frontend/src/components/layout/OrgSwitcher.tsx index 3136206c..51646fe0 100644 --- a/frontend/src/components/layout/OrgSwitcher.tsx +++ b/frontend/src/components/layout/OrgSwitcher.tsx @@ -174,7 +174,7 @@ export function OrgSwitcher({ doSwitch.reset(); setOpen((v) => !v); }} - className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-[9px] hover:bg-hover focus-visible:bg-hover outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors" + className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-[10px] border border-border-default bg-surface-0 hover:border-border-strong outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors" > {badge ?? (
)}
-
+
{orgName}
-