chore: auto-commit uncommitted changes
This commit is contained in:
parent
70baa67dfd
commit
77e5177739
Binary file not shown.
Binary file not shown.
Binary file not shown.
28
api/chat.go
28
api/chat.go
|
|
@ -12,6 +12,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mish/dealspace/lib"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChatRequest is the incoming chat message from the client.
|
// ChatRequest is the incoming chat message from the client.
|
||||||
|
|
@ -291,12 +293,28 @@ func callAnthropicAPI(userMessage string, history []Message) (string, error) {
|
||||||
return "", fmt.Errorf("FIREWORKS_API_KEY not set")
|
return "", fmt.Errorf("FIREWORKS_API_KEY not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
// System prompt first, then history, then new message
|
// Convert Message slice to map slice for sanitization
|
||||||
messages := []OAIMessage{{Role: "system", Content: ariaSystemPrompt}}
|
historyMaps := make([]map[string]string, len(history))
|
||||||
for _, m := range history {
|
for i, m := range history {
|
||||||
messages = append(messages, OAIMessage{Role: m.Role, Content: m.Content})
|
historyMaps[i] = map[string]string{
|
||||||
|
"role": m.Role,
|
||||||
|
"content": m.Content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use sanitization layer to build safe messages
|
||||||
|
safeMessages, violations := lib.BuildSafeChatMessages(ariaSystemPrompt, historyMaps, userMessage, 4000)
|
||||||
|
if len(violations) > 0 {
|
||||||
|
log.Printf("Chat sanitization violations: %v", violations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to OAI format
|
||||||
|
messages := make([]OAIMessage, len(safeMessages))
|
||||||
|
for i, m := range safeMessages {
|
||||||
|
content, _ := m["content"].(string)
|
||||||
|
role, _ := m["role"].(string)
|
||||||
|
messages[i] = OAIMessage{Role: role, Content: content}
|
||||||
}
|
}
|
||||||
messages = append(messages, OAIMessage{Role: "user", Content: userMessage})
|
|
||||||
|
|
||||||
reqBody := OAIRequest{
|
reqBody := OAIRequest{
|
||||||
Model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
Model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||||
|
|
|
||||||
|
|
@ -1362,6 +1362,7 @@ func (h *Handlers) orgToMap(org *lib.Entry) map[string]any {
|
||||||
result["name"] = orgData.Name
|
result["name"] = orgData.Name
|
||||||
result["domains"] = orgData.Domains
|
result["domains"] = orgData.Domains
|
||||||
result["role"] = orgData.Role
|
result["role"] = orgData.Role
|
||||||
|
result["is_us"] = orgData.IsUs
|
||||||
result["logo"] = orgData.Logo
|
result["logo"] = orgData.Logo
|
||||||
result["website"] = orgData.Website
|
result["website"] = orgData.Website
|
||||||
result["description"] = orgData.Description
|
result["description"] = orgData.Description
|
||||||
|
|
@ -1860,6 +1861,7 @@ func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Domains []string `json:"domains"`
|
Domains []string `json:"domains"`
|
||||||
Role *string `json:"role"`
|
Role *string `json:"role"`
|
||||||
|
IsUs *bool `json:"is_us"` // mark this org as "us" — resets others
|
||||||
Website *string `json:"website"`
|
Website *string `json:"website"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Industry *string `json:"industry"`
|
Industry *string `json:"industry"`
|
||||||
|
|
@ -1907,6 +1909,28 @@ func (h *Handlers) UpdateOrg(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
orgData.Role = *req.Role
|
orgData.Role = *req.Role
|
||||||
}
|
}
|
||||||
|
if req.IsUs != nil && *req.IsUs {
|
||||||
|
// Clear is_us on all other orgs first
|
||||||
|
orgs, _ := lib.EntryRead(h.DB, h.Cfg, actorID, "", lib.EntryFilter{Type: lib.TypeOrganization})
|
||||||
|
for _, other := range orgs {
|
||||||
|
if other.EntryID == orgID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var otherData lib.OrgData
|
||||||
|
if other.DataText != "" {
|
||||||
|
json.Unmarshal([]byte(other.DataText), &otherData)
|
||||||
|
}
|
||||||
|
if otherData.IsUs {
|
||||||
|
otherData.IsUs = false
|
||||||
|
dataJSON, _ := json.Marshal(otherData)
|
||||||
|
other.DataText = string(dataJSON)
|
||||||
|
_ = lib.EntryWrite(h.DB, h.Cfg, actorID, &other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
orgData.IsUs = true
|
||||||
|
} else if req.IsUs != nil && !*req.IsUs {
|
||||||
|
orgData.IsUs = false
|
||||||
|
}
|
||||||
if req.Website != nil {
|
if req.Website != nil {
|
||||||
orgData.Website = *req.Website
|
orgData.Website = *req.Website
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ func updateRequestStatusTool() mcp.Tool {
|
||||||
return mcp.NewTool("update_request_status",
|
return mcp.NewTool("update_request_status",
|
||||||
mcp.WithDescription("Update the status of a request"),
|
mcp.WithDescription("Update the status of a request"),
|
||||||
mcp.WithString("request_id", mcp.Description("The request ID"), mcp.Required()),
|
mcp.WithString("request_id", mcp.Description("The request ID"), mcp.Required()),
|
||||||
mcp.WithString("status", mcp.Description("New status: open, in_process, partial, or complete"), mcp.Required()),
|
mcp.WithString("status", mcp.Description("New status: open, assigned, answered, review, or published"), mcp.Required()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,9 +334,9 @@ func updateRequestStatusHandler(db *lib.DB, cfg *lib.Config) server.ToolHandlerF
|
||||||
return mcp.NewToolResultError("status is required"), nil
|
return mcp.NewToolResultError("status is required"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
validStatuses := map[string]bool{"open": true, "in_process": true, "partial": true, "complete": true}
|
validStatuses := map[string]bool{"open": true, "assigned": true, "answered": true, "review": true, "published": true}
|
||||||
if !validStatuses[status] {
|
if !validStatuses[status] {
|
||||||
return mcp.NewToolResultError("Invalid status. Must be: open, in_process, partial, or complete"), nil
|
return mcp.NewToolResultError("Invalid status. Must be: open, assigned, answered, review, or published"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := lib.EntryByID(db, cfg, requestID)
|
entry, err := lib.EntryByID(db, cfg, requestID)
|
||||||
|
|
|
||||||
162
lib/llm.go
162
lib/llm.go
|
|
@ -6,9 +6,171 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// InjectionDetection contains patterns that indicate potential LLM injection attacks
|
||||||
|
var injectionPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)(ignore|forget|disregard)\s+(all|previous|your)\s+(instructions|prompts|training)`),
|
||||||
|
regexp.MustCompile(`(?i)system\s*:\s*you\s+are\s+now`),
|
||||||
|
regexp.MustCompile(`(?i)<\s*(system|assistant|user)\s*>`),
|
||||||
|
regexp.MustCompile(`(?i)\[\s*(system|assistant|user)\s*\]`),
|
||||||
|
regexp.MustCompile(`(?i)\{\s*(system|assistant|user)\s*\}`),
|
||||||
|
regexp.MustCompile(`(?i)you\s+are\s+(an?\s+)?(attacker|hacker|malicious)`),
|
||||||
|
regexp.MustCompile(`(?i)output\s*:\s*.*(?:password|secret|key|token)`),
|
||||||
|
regexp.MustCompile(`(?i)prompt\s*:\s*.*(?:override|bypass|ignore)`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeResult indicates the outcome of sanitization
|
||||||
|
type SanitizeResult struct {
|
||||||
|
Cleaned string
|
||||||
|
Violations []string
|
||||||
|
Blocked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeUserInput sanitizes user input for LLM prompts (chat endpoint).
|
||||||
|
// Implements defense-in-depth: validation, injection detection, and message delimiters.
|
||||||
|
func SanitizeUserInput(input string, maxLen int) SanitizeResult {
|
||||||
|
result := SanitizeResult{Cleaned: input}
|
||||||
|
|
||||||
|
// Layer 1: Length enforcement
|
||||||
|
if maxLen <= 0 {
|
||||||
|
maxLen = 4000 // Default limit
|
||||||
|
}
|
||||||
|
if len(input) > maxLen {
|
||||||
|
result.Cleaned = input[:maxLen]
|
||||||
|
result.Violations = append(result.Violations, "input_truncated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: Injection pattern detection
|
||||||
|
for _, pattern := range injectionPatterns {
|
||||||
|
if matches := pattern.FindAllString(result.Cleaned, -1); len(matches) > 0 {
|
||||||
|
result.Violations = append(result.Violations, "injection_pattern_detected")
|
||||||
|
result.Blocked = true
|
||||||
|
// Replace matches with safe placeholders
|
||||||
|
result.Cleaned = pattern.ReplaceAllString(result.Cleaned, "[FILTERED]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 3: Escape XML-like delimiters that could confuse message boundaries
|
||||||
|
result.Cleaned = strings.ReplaceAll(result.Cleaned, "<system>", "<system>")
|
||||||
|
result.Cleaned = strings.ReplaceAll(result.Cleaned, "</system>", "</system>")
|
||||||
|
result.Cleaned = strings.ReplaceAll(result.Cleaned, "<assistant>", "<assistant>")
|
||||||
|
result.Cleaned = strings.ReplaceAll(result.Cleaned, "</assistant>", "</assistant>")
|
||||||
|
result.Cleaned = strings.ReplaceAll(result.Cleaned, "<user>", "<user>")
|
||||||
|
result.Cleaned = strings.ReplaceAll(result.Cleaned, "</user>", "</user>")
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapUserMessage wraps user input in XML-style delimiters for clear separation from system instructions.
|
||||||
|
// This makes it harder for user input to be interpreted as system/assistant messages.
|
||||||
|
func WrapUserMessage(content string) string {
|
||||||
|
return fmt.Sprintf("<user_message>\n%s\n</user_message>", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapSystemMessage wraps system content in XML-style delimiters.
|
||||||
|
func WrapSystemMessage(content string) string {
|
||||||
|
return fmt.Sprintf("<system_instructions>\n%s\n</system_instructions>", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeHTMLContent prepares HTML content for LLM processing (scrape endpoint).
|
||||||
|
// Removes scripts, styles, and other potentially malicious content before LLM sees it.
|
||||||
|
func SanitizeHTMLContent(html string, maxLen int) string {
|
||||||
|
if maxLen <= 0 {
|
||||||
|
maxLen = 50000 // Default limit for HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 1: Remove scripts (both <script> tags and event handlers)
|
||||||
|
// Remove <script>...</script> blocks entirely
|
||||||
|
scriptRegex := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
|
||||||
|
cleaned := scriptRegex.ReplaceAllString(html, "")
|
||||||
|
|
||||||
|
// Layer 2: Remove <style> tags
|
||||||
|
styleRegex := regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
|
||||||
|
cleaned = styleRegex.ReplaceAllString(cleaned, "")
|
||||||
|
|
||||||
|
// Layer 3: Remove onclick, onload, etc. event handlers
|
||||||
|
eventRegex := regexp.MustCompile(`(?i)\s+on\w+\s*=\s*"[^"]*"`)
|
||||||
|
cleaned = eventRegex.ReplaceAllString(cleaned, "")
|
||||||
|
|
||||||
|
eventRegex2 := regexp.MustCompile(`(?i)\s+on\w+\s*=\s*'[^']*'`)
|
||||||
|
cleaned = eventRegex2.ReplaceAllString(cleaned, "")
|
||||||
|
|
||||||
|
// Layer 4: Remove javascript: URLs
|
||||||
|
jsUrlRegex := regexp.MustCompile(`(?i)javascript\s*:\s*[^"'>\s]+`)
|
||||||
|
cleaned = jsUrlRegex.ReplaceAllString(cleaned, "")
|
||||||
|
|
||||||
|
// Layer 5: Remove data: URLs that could be SVG/script-based
|
||||||
|
dataUrlRegex := regexp.MustCompile(`(?i)data\s*:\s*[^"'>\s]+`)
|
||||||
|
cleaned = dataUrlRegex.ReplaceAllString(cleaned, "[data-url-removed]")
|
||||||
|
|
||||||
|
// Layer 6: Remove comments that might hide injection attempts
|
||||||
|
commentRegex := regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||||
|
cleaned = commentRegex.ReplaceAllString(cleaned, "")
|
||||||
|
|
||||||
|
// Layer 7: Normalize whitespace to reduce hidden characters
|
||||||
|
cleaned = regexp.MustCompile(`[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`).ReplaceAllString(cleaned, "")
|
||||||
|
|
||||||
|
// Layer 8: Length enforcement
|
||||||
|
if len(cleaned) > maxLen {
|
||||||
|
cleaned = cleaned[:maxLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSafeChatMessages constructs a properly delimited message list for chat LLM calls.
|
||||||
|
// Takes raw user input and sanitizes it before inclusion in messages.
|
||||||
|
func BuildSafeChatMessages(systemPrompt string, history []map[string]string, userMessage string, maxUserLen int) ([]map[string]interface{}, []string) {
|
||||||
|
messages := []map[string]interface{}{
|
||||||
|
{"role": "system", "content": WrapSystemMessage(systemPrompt)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process history through sanitization
|
||||||
|
for _, msg := range history {
|
||||||
|
role := msg["role"]
|
||||||
|
content := msg["content"]
|
||||||
|
|
||||||
|
// Sanitize based on role
|
||||||
|
if role == "user" {
|
||||||
|
result := SanitizeUserInput(content, maxUserLen)
|
||||||
|
content = WrapUserMessage(result.Cleaned)
|
||||||
|
}
|
||||||
|
// Assistant messages are trusted (came from our system)
|
||||||
|
|
||||||
|
messages = append(messages, map[string]interface{}{
|
||||||
|
"role": role,
|
||||||
|
"content": content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize current user message
|
||||||
|
result := SanitizeUserInput(userMessage, maxUserLen)
|
||||||
|
messages = append(messages, map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": WrapUserMessage(result.Cleaned),
|
||||||
|
})
|
||||||
|
|
||||||
|
return messages, result.Violations
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSafeScrapePrompt constructs a safe prompt with sanitized HTML content for scraping.
|
||||||
|
// The HTML is wrapped in delimiters to isolate it from instructions.
|
||||||
|
func BuildSafeScrapePrompt(instructions string, htmlContent string, domain string, maxHtmlLen int) string {
|
||||||
|
sanitizedHTML := SanitizeHTMLContent(htmlContent, maxHtmlLen)
|
||||||
|
|
||||||
|
return fmt.Sprintf(`%s
|
||||||
|
|
||||||
|
The following is sanitized HTML content from %s, enclosed in delimiters. Analyze this content only.
|
||||||
|
|
||||||
|
<scraped_html>
|
||||||
|
%s
|
||||||
|
</scraped_html>`,
|
||||||
|
instructions, domain, sanitizedHTML)
|
||||||
|
}
|
||||||
|
|
||||||
// CallOpenRouter sends a request to OpenRouter (OpenAI-compatible API).
|
// CallOpenRouter sends a request to OpenRouter (OpenAI-compatible API).
|
||||||
func CallOpenRouter(apiKey, model string, messages []map[string]interface{}, maxTokens int) (string, error) {
|
func CallOpenRouter(apiKey, model string, messages []map[string]interface{}, maxTokens int) (string, error) {
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,8 @@ func ScrapeOrg(apiKey, domain string) (*ScrapedOrg, error) {
|
||||||
return nil, fmt.Errorf("could not fetch %s", base)
|
return nil, fmt.Errorf("could not fetch %s", base)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ask LLM to find relevant pages
|
// Ask LLM to find relevant pages using sanitized prompt
|
||||||
discoverPrompt := fmt.Sprintf(`You are analyzing the HTML of %s to find pages that contain:
|
discoverInstructions := fmt.Sprintf(`You are analyzing the HTML of %s to find pages that contain:
|
||||||
1. Team / leadership / people / staff pages (with bios, headshots, names)
|
1. Team / leadership / people / staff pages (with bios, headshots, names)
|
||||||
2. About / company info pages
|
2. About / company info pages
|
||||||
3. Contact / office address pages
|
3. Contact / office address pages
|
||||||
|
|
@ -82,10 +82,9 @@ Look at the navigation, footer, and links in the HTML. Return a JSON array of up
|
||||||
Return ONLY a JSON array of strings, no markdown:
|
Return ONLY a JSON array of strings, no markdown:
|
||||||
["https://%s/about", "https://%s/team", ...]
|
["https://%s/about", "https://%s/team", ...]
|
||||||
|
|
||||||
If you cannot find any relevant links, return an empty array: []
|
If you cannot find any relevant links, return an empty array: []`, domain, domain, domain, domain)
|
||||||
|
|
||||||
HTML:
|
discoverPrompt := BuildSafeScrapePrompt(discoverInstructions, homepage, domain, 50000)
|
||||||
%s`, domain, domain, domain, domain, homepage)
|
|
||||||
|
|
||||||
discoverMessages := []map[string]interface{}{
|
discoverMessages := []map[string]interface{}{
|
||||||
{"role": "user", "content": discoverPrompt},
|
{"role": "user", "content": discoverPrompt},
|
||||||
|
|
@ -115,8 +114,8 @@ HTML:
|
||||||
|
|
||||||
html := allHTML.String()
|
html := allHTML.String()
|
||||||
|
|
||||||
// Pass 2: extract structured data
|
// Pass 2: extract structured data using sanitized prompt
|
||||||
prompt := fmt.Sprintf(`Extract structured data from this company website. Domain: %s
|
extractInstructions := fmt.Sprintf(`Extract structured data from this company website. Domain: %s
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- Extract EVERY person mentioned — do not skip anyone
|
- Extract EVERY person mentioned — do not skip anyone
|
||||||
|
|
@ -157,10 +156,9 @@ Return a single JSON object:
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Return ONLY valid JSON — no markdown, no explanation. All text values must be clean plain text — decode any HTML entities (e.g. ’ → ', & → &).
|
Return ONLY valid JSON — no markdown, no explanation. All text values must be clean plain text — decode any HTML entities (e.g. ’ → ', & → &).`, domain, domain, domain, domain)
|
||||||
|
|
||||||
HTML:
|
prompt := BuildSafeScrapePrompt(extractInstructions, html, domain, 50000)
|
||||||
%s`, domain, domain, domain, domain, html)
|
|
||||||
|
|
||||||
messages := []map[string]interface{}{
|
messages := []map[string]interface{}{
|
||||||
{"role": "user", "content": prompt},
|
{"role": "user", "content": prompt},
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ type OrgData struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"]
|
Domains []string `json:"domains"` // required, e.g. ["kaseya.com","datto.com"]
|
||||||
Role string `json:"role"` // seller | buyer | ib | advisor
|
Role string `json:"role"` // seller | buyer | ib | advisor
|
||||||
|
IsUs bool `json:"is_us,omitempty"` // is this organization "us" (the primary org)
|
||||||
Logo string `json:"logo,omitempty"`
|
Logo string `json:"logo,omitempty"`
|
||||||
Website string `json:"website,omitempty"`
|
Website string `json:"website,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
|
|
@ -280,7 +281,7 @@ type RequestData struct {
|
||||||
Section string `json:"section"` // e.g. "Financial", "Legal"
|
Section string `json:"section"` // e.g. "Financial", "Legal"
|
||||||
Description string `json:"description"` // full detail / context
|
Description string `json:"description"` // full detail / context
|
||||||
Priority string `json:"priority"` // critical | high | medium | low
|
Priority string `json:"priority"` // critical | high | medium | low
|
||||||
Status string `json:"status"` // open | in_process | partial | complete
|
Status string `json:"status"` // open | assigned | answered | review | published
|
||||||
AssigneeID string `json:"assignee_id,omitempty"`
|
AssigneeID string `json:"assignee_id,omitempty"`
|
||||||
AssigneeName string `json:"assignee_name,omitempty"`
|
AssigneeName string `json:"assignee_name,omitempty"`
|
||||||
ReviewerID string `json:"reviewer_id,omitempty"`
|
ReviewerID string `json:"reviewer_id,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,10 @@
|
||||||
<p class="text-xs text-[#64748b] mt-1">Comma-separated. Used to validate invite emails.</p></div>
|
<p class="text-xs text-[#64748b] mt-1">Comma-separated. Used to validate invite emails.</p></div>
|
||||||
<div class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Logo URL</label>
|
<div class="col-span-2"><label class="block text-xs text-[#b0bec5] mb-1">Logo URL</label>
|
||||||
<input id="eLogo" type="text" placeholder="https://..." class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]" oninput="previewEditLogo()"></div>
|
<input id="eLogo" type="text" placeholder="https://..." class="w-full px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white text-sm focus:outline-none focus:border-[#c9a84c]" oninput="previewEditLogo()"></div>
|
||||||
|
<div class="col-span-2 flex items-center gap-2 mt-2">
|
||||||
|
<input id="eIsUs" type="checkbox" class="accent-[#c9a84c] w-4 h-4">
|
||||||
|
<label for="eIsUs" class="text-sm text-white cursor-pointer">This is "us" — our organization</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Members Tab -->
|
<!-- Members Tab -->
|
||||||
|
|
@ -172,10 +176,14 @@
|
||||||
const domains = Array.isArray(o.domains) ? o.domains : [];
|
const domains = Array.isArray(o.domains) ? o.domains : [];
|
||||||
const memberCount = Array.isArray(o.members) ? o.members.length : 0;
|
const memberCount = Array.isArray(o.members) ? o.members.length : 0;
|
||||||
const logo = o.logo || '';
|
const logo = o.logo || '';
|
||||||
|
const isUs = o.is_us;
|
||||||
return '<div onclick="openEditModal(\'' + escHtml(o.entry_id) + '\')" class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5 cursor-pointer hover:border-white/[0.2] transition">'
|
return '<div onclick="openEditModal(\'' + escHtml(o.entry_id) + '\')" class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-5 cursor-pointer hover:border-white/[0.2] transition">'
|
||||||
+ '<div class="flex items-start gap-3 mb-3">'
|
+ '<div class="flex items-start gap-3 mb-3">'
|
||||||
+ (logo ? '<div class="w-10 h-10 rounded-lg border border-white/[0.08] overflow-hidden bg-white shrink-0 flex items-center justify-center"><img src="' + escHtml(logo) + '" class="max-w-full max-h-full object-contain" onerror="this.parentElement.style.display=\'none\'"></div>' : '')
|
+ (logo ? '<div class="w-10 h-10 rounded-lg border border-white/[0.08] overflow-hidden bg-white shrink-0 flex items-center justify-center"><img src="' + escHtml(logo) + '" class="max-w-full max-h-full object-contain" onerror="this.parentElement.style.display=\'none\'"></div>' : '')
|
||||||
|
+ '<div class="flex-1 min-w-0">'
|
||||||
+ '<h3 class="text-white font-semibold leading-tight truncate flex-1">' + escHtml(name) + '</h3>'
|
+ '<h3 class="text-white font-semibold leading-tight truncate flex-1">' + escHtml(name) + '</h3>'
|
||||||
|
+ (isUs ? '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[#c9a84c]/20 text-[#c9a84c] mt-1">★ US</span>' : '')
|
||||||
|
+ '</div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ (o.description ? '<p class="text-xs mb-2 line-clamp-2" style="color:var(--ds-tx2)">' + escHtml(o.description) + '</p>' : '')
|
+ (o.description ? '<p class="text-xs mb-2 line-clamp-2" style="color:var(--ds-tx2)">' + escHtml(o.description) + '</p>' : '')
|
||||||
+ '<div class="flex items-center justify-between mt-1">'
|
+ '<div class="flex items-center justify-between mt-1">'
|
||||||
|
|
@ -244,6 +252,7 @@
|
||||||
document.getElementById('eState').value = o.state || '';
|
document.getElementById('eState').value = o.state || '';
|
||||||
document.getElementById('eDomains').value = Array.isArray(o.domains) ? o.domains.join(', ') : '';
|
document.getElementById('eDomains').value = Array.isArray(o.domains) ? o.domains.join(', ') : '';
|
||||||
document.getElementById('eLogo').value = o.logo || '';
|
document.getElementById('eLogo').value = o.logo || '';
|
||||||
|
document.getElementById('eIsUs').checked = !!o.is_us;
|
||||||
document.getElementById('editModalError').classList.add('hidden');
|
document.getElementById('editModalError').classList.add('hidden');
|
||||||
document.getElementById('editOrgTitle').textContent = o.name || 'Edit Organization';
|
document.getElementById('editOrgTitle').textContent = o.name || 'Edit Organization';
|
||||||
const logo = o.logo || '';
|
const logo = o.logo || '';
|
||||||
|
|
@ -293,6 +302,7 @@
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name, domains,
|
name, domains,
|
||||||
|
is_us: document.getElementById('eIsUs').checked,
|
||||||
description: document.getElementById('eDesc').value.trim(),
|
description: document.getElementById('eDesc').value.trim(),
|
||||||
industry: document.getElementById('eIndustry').value.trim(),
|
industry: document.getElementById('eIndustry').value.trim(),
|
||||||
website: document.getElementById('eWebsite').value.trim(),
|
website: document.getElementById('eWebsite').value.trim(),
|
||||||
|
|
|
||||||
|
|
@ -627,8 +627,14 @@
|
||||||
|
|
||||||
// Badge helpers
|
// Badge helpers
|
||||||
const priStyles = { critical:'background:rgba(239,68,68,.15);color:#f87171', high:'background:rgba(251,146,60,.15);color:#fb923c', medium:'background:rgba(250,204,21,.15);color:#facc15', low:'background:rgba(148,163,184,.15);color:#b0bec5' };
|
const priStyles = { critical:'background:rgba(239,68,68,.15);color:#f87171', high:'background:rgba(251,146,60,.15);color:#fb923c', medium:'background:rgba(250,204,21,.15);color:#facc15', low:'background:rgba(148,163,184,.15);color:#b0bec5' };
|
||||||
const statStyles = { open:'background:rgba(96,165,250,.15);color:#60a5fa', in_process:'background:rgba(251,191,36,.15);color:#fbbf24', partial:'background:rgba(168,85,247,.15);color:#a855f7', complete:'background:rgba(74,222,128,.15);color:#4ade80' };
|
const statStyles = {
|
||||||
const statLabels = { open:'Open', in_process:'In Process', partial:'Partial', complete:'Complete' };
|
open:'background:rgba(107,114,128,.15);color:#9CA3AF',
|
||||||
|
assigned:'background:rgba(59,130,246,.15);color:#3b82f6',
|
||||||
|
answered:'background:rgba(168,85,247,.15);color:#a855f7',
|
||||||
|
review:'background:rgba(201,168,76,.15);color:#C9A84C',
|
||||||
|
published:'background:rgba(34,197,94,.15);color:#22c55e'
|
||||||
|
};
|
||||||
|
const statLabels = { open:'Open', assigned:'Assigned', answered:'Answered', review:'Review', published:'Published' };
|
||||||
|
|
||||||
function priorityBadge(p) {
|
function priorityBadge(p) {
|
||||||
return `<span class="badge" style="${priStyles[p]||priStyles.medium}">${escHtml(p||'medium')}</span>`;
|
return `<span class="badge" style="${priStyles[p]||priStyles.medium}">${escHtml(p||'medium')}</span>`;
|
||||||
|
|
@ -867,7 +873,7 @@
|
||||||
const prioritySelect = `<select class="badge" style="${priStyles[curPri]||priStyles.medium};border:none;cursor:pointer;font-size:11px;padding:2px 14px 2px 6px;border-radius:9999px;-webkit-appearance:none;appearance:none;outline:none;font-weight:500" onchange="updateField('${eid}','priority',this.value)">${priOpts}</select>`;
|
const prioritySelect = `<select class="badge" style="${priStyles[curPri]||priStyles.medium};border:none;cursor:pointer;font-size:11px;padding:2px 14px 2px 6px;border-radius:9999px;-webkit-appearance:none;appearance:none;outline:none;font-weight:500" onchange="updateField('${eid}','priority',this.value)">${priOpts}</select>`;
|
||||||
|
|
||||||
// Status select
|
// Status select
|
||||||
const statOpts = ['open','in_process','partial','complete'].map(o =>
|
const statOpts = ['open','assigned','answered','review','published'].map(o =>
|
||||||
`<option value="${o}" ${o===curStat?'selected':''}>${statLabels[o]}</option>`
|
`<option value="${o}" ${o===curStat?'selected':''}>${statLabels[o]}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
const statusSelect = `<select class="badge" style="${statStyles[curStat]||statStyles.open};border:none;cursor:pointer;font-size:11px;padding:2px 14px 2px 6px;border-radius:9999px;-webkit-appearance:none;appearance:none;outline:none;font-weight:500" onchange="updateField('${eid}','status',this.value)">${statOpts}</select>`;
|
const statusSelect = `<select class="badge" style="${statStyles[curStat]||statStyles.open};border:none;cursor:pointer;font-size:11px;padding:2px 14px 2px 6px;border-radius:9999px;-webkit-appearance:none;appearance:none;outline:none;font-weight:500" onchange="updateField('${eid}','status',this.value)">${statOpts}</select>`;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
</select></div>
|
</select></div>
|
||||||
<div class="flex-1"><label class="block text-xs font-medium mb-1.5" style="color:var(--ds-tx3)">Status</label>
|
<div class="flex-1"><label class="block text-xs font-medium mb-1.5" style="color:var(--ds-tx3)">Status</label>
|
||||||
<select id="editReqStatus" class="w-full px-3 py-2 rounded text-sm focus:outline-none" style="background:var(--ds-sf);border:1px solid var(--ds-bd);color:var(--ds-tx)">
|
<select id="editReqStatus" class="w-full px-3 py-2 rounded text-sm focus:outline-none" style="background:var(--ds-sf);border:1px solid var(--ds-bd);color:var(--ds-tx)">
|
||||||
<option value="open">Open</option><option value="in_process">In Process</option><option value="partial">Partial</option><option value="complete">Complete</option>
|
<option value="open">Open</option><option value="assigned">Assigned</option><option value="answered">Answered</option><option value="review">Review</option><option value="published">Published</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
</div>
|
</div>
|
||||||
<div><label class="block text-xs font-medium mb-1.5" style="color:var(--ds-tx3)">Description (optional)</label>
|
<div><label class="block text-xs font-medium mb-1.5" style="color:var(--ds-tx3)">Description (optional)</label>
|
||||||
|
|
@ -112,7 +112,13 @@
|
||||||
return r === 'buyer' || r === 'seller' || r === 'advisor';
|
return r === 'buyer' || r === 'seller' || r === 'advisor';
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors = { open: 'bg-yellow-500/20 text-yellow-300', answered: 'bg-green-500/20 text-green-300', closed: 'bg-gray-500/20 text-gray-300', 'under-review': 'bg-blue-500/20 text-blue-300' };
|
const statusColors = {
|
||||||
|
open: 'bg-gray-500/20 text-gray-300',
|
||||||
|
assigned: 'bg-blue-500/20 text-blue-300',
|
||||||
|
answered: 'bg-purple-500/20 text-purple-300',
|
||||||
|
review: 'bg-yellow-500/20 text-yellow-300',
|
||||||
|
published: 'bg-green-500/20 text-green-300'
|
||||||
|
};
|
||||||
const priorityColors = { high: 'bg-red-500/20 text-red-300', medium: 'bg-yellow-500/20 text-yellow-300', low: 'bg-blue-500/20 text-blue-300' };
|
const priorityColors = { high: 'bg-red-500/20 text-red-300', medium: 'bg-yellow-500/20 text-yellow-300', low: 'bg-blue-500/20 text-blue-300' };
|
||||||
|
|
||||||
let currentRequest = null;
|
let currentRequest = null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue