diff --git a/internal/api/teams_config_handler.go b/internal/api/teams_config_handler.go index 152c47a4..6add757b 100644 --- a/internal/api/teams_config_handler.go +++ b/internal/api/teams_config_handler.go @@ -23,13 +23,13 @@ func NewTeamsConfigHandler(db *sql.DB) *TeamsConfigHandler { } } -type teamsConfigResponse struct { +type TeamsConfigResponse struct { Configured bool `json:"configured"` BotAppID string `json:"bot_app_id,omitempty"` TenantID string `json:"tenant_id,omitempty"` } -type teamsConfigUpdateRequest struct { +type TeamsConfigUpdateRequest struct { BotAppID string `json:"bot_app_id"` BotPassword string `json:"bot_password"` } @@ -43,12 +43,12 @@ func (h *TeamsConfigHandler) GetTeamsConfig(c echo.Context) error { cfg, err := h.storage.GetTeamsConfig(c.Request().Context(), permCtx.OrgID) if err != nil { if err == sql.ErrNoRows { - return c.JSON(http.StatusOK, teamsConfigResponse{Configured: false}) + return c.JSON(http.StatusOK, TeamsConfigResponse{Configured: false}) } return echo.NewHTTPError(http.StatusInternalServerError, "failed to get Teams config") } - return c.JSON(http.StatusOK, teamsConfigResponse{ + return c.JSON(http.StatusOK, TeamsConfigResponse{ Configured: true, BotAppID: cfg.BotAppID, TenantID: cfg.TenantID, @@ -65,7 +65,7 @@ func (h *TeamsConfigHandler) UpdateTeamsConfig(c echo.Context) error { return echo.NewHTTPError(http.StatusForbidden, "only owners can configure Teams integration") } - var req teamsConfigUpdateRequest + var req TeamsConfigUpdateRequest if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") } @@ -97,7 +97,7 @@ func (h *TeamsConfigHandler) UpdateTeamsConfig(c echo.Context) error { log.Printf("[TeamsConfig] Org %d: Teams bot configured with app ID %s", permCtx.OrgID, req.BotAppID) - return c.JSON(http.StatusOK, teamsConfigResponse{ + return c.JSON(http.StatusOK, TeamsConfigResponse{ Configured: true, BotAppID: cfg.BotAppID, }) diff --git a/osv-scanner.toml b/osv-scanner.toml index 62c1233e..74d0eb54 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -1,8 +1,4 @@ # OSV ignore list -[[ignoredVulns]] -id = "GHSA-64mm-vxmg-q3vj" -reason = "Webpack dev-server proxy configuration does not use the vulnerable host+path router option, and it is a development-only dependency that is never deployed to production." - [[ignoredVulns]] id = "GO-2026-5932" reason = "False positive — golang.org/x/crypto/openpgp sub-package is never imported by this project; only golang.org/x/crypto/bcrypt is used." diff --git a/ui/package-lock.json b/ui/package-lock.json index cf767164..d7eda5c2 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -29,6 +29,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.62.0", "react-hot-toast": "^2.6.0", + "react-icons": "^5.7.0", "react-redux": "^9.2.0", "react-router": "^7.18.0", "react-router-dom": "^7.18.0", @@ -17162,6 +17163,15 @@ "react-dom": ">=16" } }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/ui/package.json b/ui/package.json index 742d5f4e..7372429d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -103,6 +103,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.62.0", "react-hot-toast": "^2.6.0", + "react-icons": "^5.7.0", "react-redux": "^9.2.0", "react-router": "^7.18.0", "react-router-dom": "^7.18.0", diff --git a/ui/src/components/Connector/ProviderSelection.tsx b/ui/src/components/Connector/ProviderSelection.tsx index 23f65667..eef35999 100644 --- a/ui/src/components/Connector/ProviderSelection.tsx +++ b/ui/src/components/Connector/ProviderSelection.tsx @@ -23,7 +23,7 @@ const ProviderSelection: React.FC = () => { } className="h-24 flex-col" onClick={() => navigate('/git/bitbucket/manual')}> Bitbucket - } className="h-24 flex-col" onClick={() => navigate('/git/gitea/manual')}> + } className="h-24 flex-col" onClick={() => navigate('/git/gitea/manual')}> Gitea } className="h-24 flex-col" onClick={() => navigate('/git/azuredevops/manual')}> diff --git a/ui/src/components/Navbar/NavMegaMenu.tsx b/ui/src/components/Navbar/NavMegaMenu.tsx new file mode 100644 index 00000000..1c6ef395 --- /dev/null +++ b/ui/src/components/Navbar/NavMegaMenu.tsx @@ -0,0 +1,304 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import classNames from 'classnames'; +import { Icons } from '../UIPrimitives'; +import { flattenMegaMenuSections, MegaMenuGroupNode, MegaMenuLinkNode, MegaMenuNode, MegaMenuSearchEntry, MegaMenuSection } from './megaMenuData'; +import { shortcutKeyLabel } from '../../utils/platform'; + + +// Ranking tiers (lower score wins). Each tier's floor is set well above the previous tier's +// realistic ceiling (UI labels/breadcrumbs here never approach hundreds of characters), so a +// worse-tier match can never outrank a better-tier one: +// 0..~100 substring match on the entry's own name (ranked by how early it appears) +// SUBSEQUENCE_MATCH_SCORE (1000) in-order subsequence match on the name (typo/partial tolerance) +// BREADCRUMB_MATCH_OFFSET (2000) + the above same two tiers, but matched on the breadcrumb instead +const SUBSEQUENCE_MATCH_SCORE = 1000; +const BREADCRUMB_MATCH_OFFSET = 2000; + +const matchScore = (query: string, target: string): number | null => { + const q = query.toLowerCase(); + const t = target.toLowerCase(); + if (!q) return null; + const substringIndex = t.indexOf(q); + if (substringIndex !== -1) return substringIndex; + + let qi = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) qi += 1; + } + return qi === q.length ? SUBSEQUENCE_MATCH_SCORE : null; +}; + +const scoreSearchEntry = (entry: MegaMenuSearchEntry, query: string): number | null => { + const nameScore = matchScore(query, entry.name); + if (nameScore !== null) return nameScore; + const breadcrumbScore = matchScore(query, entry.breadcrumb); + return breadcrumbScore === null ? null : breadcrumbScore + BREADCRUMB_MATCH_OFFSET; +}; + +type NavMegaMenuProps = { + isOpen: boolean; + onClose: () => void; + sections: MegaMenuSection[]; + highlightKey?: string | null; + searchFocusToken?: number; +}; + +const LinkRow: React.FC<{ node: MegaMenuLinkNode; goTo: (path: string) => void }> = ({ node, goTo }) => ( +
No matches for "{query}"
+ ) : ( +