diff --git a/internal/http/vault_handlers.go b/internal/http/vault_handlers.go index f54c4fb6..b5fe2a7c 100644 --- a/internal/http/vault_handlers.go +++ b/internal/http/vault_handlers.go @@ -54,6 +54,34 @@ func (h *VaultHandler) validateTeamMembership(ctx context.Context, w http.Respon return true } +// userAccessibleTeamIDs returns the team IDs accessible by the current non-owner user. +// Returns nil if no teams are found or team store is unavailable. +func (h *VaultHandler) userAccessibleTeamIDs(ctx context.Context) []string { + userID := store.UserIDFromContext(ctx) + if userID == "" || h.teamAccess == nil { + return nil + } + teams, err := h.teamAccess.ListUserTeams(ctx, userID) + if err != nil || len(teams) == 0 { + return nil + } + ids := make([]string, len(teams)) + for i, t := range teams { + ids[i] = t.ID.String() + } + return ids +} + +// applyNonOwnerTeamScope restricts a non-owner vault list to personal + user's teams. +func (h *VaultHandler) applyNonOwnerTeamScope(ctx context.Context, opts *store.VaultListOptions) { + if ids := h.userAccessibleTeamIDs(ctx); len(ids) > 0 { + opts.TeamIDs = ids + } else { + empty := "" + opts.TeamID = &empty + } +} + func (h *VaultHandler) RegisterRoutes(mux *http.ServeMux) { // Cross-agent endpoint (agent_id optional query param). mux.HandleFunc("GET /v1/vault/documents", h.auth(h.handleListAllDocuments)) @@ -107,10 +135,9 @@ func (h *VaultHandler) handleListAllDocuments(w http.ResponseWriter, r *http.Req return } } - // Non-owner without team_id filter: default to personal (NULL team_id) only. + // Non-owner without team_id filter: show personal + user's teams. if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { - empty := "" - opts.TeamID = &empty + h.applyNonOwnerTeamScope(r.Context(), &opts) } docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts) @@ -141,8 +168,7 @@ func (h *VaultHandler) handleListDocuments(w http.ResponseWriter, r *http.Reques } } if opts.TeamID == nil && !store.IsOwnerRole(r.Context()) { - empty := "" - opts.TeamID = &empty + h.applyNonOwnerTeamScope(r.Context(), &opts) } docs, err := h.store.ListDocuments(r.Context(), tenantID.String(), agentID, opts) @@ -224,8 +250,12 @@ func (h *VaultHandler) handleSearch(w http.ResponseWriter, r *http.Request) { } searchOpts.TeamID = &body.TeamID } else if !store.IsOwnerRole(r.Context()) { - empty := "" - searchOpts.TeamID = &empty // non-owner: personal only + if ids := h.userAccessibleTeamIDs(r.Context()); len(ids) > 0 { + searchOpts.TeamIDs = ids + } else { + empty := "" + searchOpts.TeamID = &empty + } } results, err := h.store.Search(r.Context(), searchOpts) diff --git a/internal/store/pg/vault_documents.go b/internal/store/pg/vault_documents.go index 97d72936..7784766c 100644 --- a/internal/store/pg/vault_documents.go +++ b/internal/store/pg/vault_documents.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "strings" "log/slog" "sort" "time" @@ -13,6 +14,29 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +// appendTeamFilter appends the team_id clause to a vault query. +// TeamIDs (personal + listed teams) takes precedence over TeamID (single team / personal-only). +func appendTeamFilter(q string, args []any, p int, teamID *string, teamIDs []string) (string, []any, int) { + if len(teamIDs) > 0 { + ph := make([]string, len(teamIDs)) + for i, id := range teamIDs { + ph[i] = fmt.Sprintf("$%d", p) + args = append(args, mustParseUUID(id)) + p++ + } + q += " AND (team_id IS NULL OR team_id IN (" + strings.Join(ph, ",") + "))" + } else if teamID != nil { + if *teamID != "" { + q += fmt.Sprintf(" AND team_id = $%d", p) + args = append(args, mustParseUUID(*teamID)) + p++ + } else { + q += " AND team_id IS NULL" + } + } + return q, args, p +} + // PGVaultStore implements store.VaultStore backed by PostgreSQL. type PGVaultStore struct { db *sql.DB @@ -177,15 +201,7 @@ func (s *PGVaultStore) ListDocuments(ctx context.Context, tenantID, agentID stri p++ } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, mustParseUUID(*opts.TeamID)) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = appendTeamFilter(q, args, p, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) @@ -237,15 +253,7 @@ func (s *PGVaultStore) CountDocuments(ctx context.Context, tenantID, agentID str args = append(args, aid) p++ } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, mustParseUUID(*opts.TeamID)) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = appendTeamFilter(q, args, p, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) args = append(args, opts.Scope) @@ -281,17 +289,8 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions tid := mustParseUUID(opts.TenantID) aid := mustParseUUID(opts.AgentID) - // Parse team_id filter. - var teamUUID *uuid.UUID - if opts.TeamID != nil { - if *opts.TeamID != "" { - t := mustParseUUID(*opts.TeamID) - teamUUID = &t - } - // opts.TeamID == ptr-to-empty → teamUUID stays nil → filters for team_id IS NULL - } - // opts.TeamID == nil → teamUUID stays nil and useTeamFilter = false (handled below) - useTeamFilter := opts.TeamID != nil + // Build team filter for search sub-queries. + tf := buildSearchTeamFilter(opts.TeamID, opts.TeamIDs) maxResults := opts.MaxResults if maxResults <= 0 { @@ -299,7 +298,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions } // FTS search - ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, teamUUID, useTeamFilter, opts.Scope, opts.DocTypes, maxResults*2) + ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2) if err != nil { return nil, err } @@ -310,7 +309,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions vecs, embErr := s.embProvider.Embed(ctx, []string{opts.Query}) if embErr == nil && len(vecs) > 0 { var vecErr error - vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, teamUUID, useTeamFilter, opts.Scope, opts.DocTypes, maxResults*2) + vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2) if vecErr != nil { slog.Debug("vault.vector_search_fallback", "err", vecErr) vecResults = nil @@ -334,7 +333,55 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions return merged, nil } -func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, agentID uuid.UUID, teamID *uuid.UUID, useTeamFilter bool, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { +// searchTeamFilter holds pre-parsed team filter info for search sub-queries. +type searchTeamFilter struct { + teamIDs []uuid.UUID // personal + these teams (len > 0 = multi-team mode) + teamID *uuid.UUID // single team filter (nil + active = personal-only) + active bool // whether any team filter is applied +} + +func buildSearchTeamFilter(teamID *string, teamIDs []string) searchTeamFilter { + if len(teamIDs) > 0 { + uuids := make([]uuid.UUID, len(teamIDs)) + for i, id := range teamIDs { + uuids[i] = mustParseUUID(id) + } + return searchTeamFilter{teamIDs: uuids, active: true} + } + if teamID != nil { + if *teamID != "" { + t := mustParseUUID(*teamID) + return searchTeamFilter{teamID: &t, active: true} + } + return searchTeamFilter{active: true} // personal-only + } + return searchTeamFilter{} // no filter +} + +// appendSearchTeamClause appends team filter SQL to a search query. +func (tf searchTeamFilter) append(q string, args []any, p int) (string, []any, int) { + if !tf.active { + return q, args, p + } + if len(tf.teamIDs) > 0 { + ph := make([]string, len(tf.teamIDs)) + for i, id := range tf.teamIDs { + ph[i] = fmt.Sprintf("$%d", p) + args = append(args, id) + p++ + } + q += " AND (team_id IS NULL OR team_id IN (" + strings.Join(ph, ",") + "))" + } else if tf.teamID != nil { + q += fmt.Sprintf(" AND team_id = $%d", p) + args = append(args, *tf.teamID) + p++ + } else { + q += " AND team_id IS NULL" + } + return q, args, p +} + +func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, agentID uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at, ts_rank(tsv, plainto_tsquery('simple', $1)) AS score FROM vault_documents @@ -342,15 +389,7 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, ag args := []any{query, tenantID, agentID} p := 4 - if useTeamFilter { - if teamID != nil { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, *teamID) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = tf.append(q, args, p) if scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) @@ -373,7 +412,7 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID, ag return vaultSearchRowsToResults(scanned, "vault"), nil } -func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID, agentID uuid.UUID, teamID *uuid.UUID, useTeamFilter bool, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { +func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID, agentID uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { vecStr := vectorToString(embedding) q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, metadata, created_at, updated_at, 1 - (embedding <=> $1) AS score @@ -382,15 +421,7 @@ func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, te args := []any{vecStr, tenantID, agentID} p := 4 - if useTeamFilter { - if teamID != nil { - q += fmt.Sprintf(" AND team_id = $%d", p) - args = append(args, *teamID) - p++ - } else { - q += " AND team_id IS NULL" - } - } + q, args, p = tf.append(q, args, p) if scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) diff --git a/internal/store/sqlitestore/vault_documents.go b/internal/store/sqlitestore/vault_documents.go index 56b3a891..5b8a5e7a 100644 --- a/internal/store/sqlitestore/vault_documents.go +++ b/internal/store/sqlitestore/vault_documents.go @@ -15,6 +15,25 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) +// sqliteAppendTeamFilter appends the team_id clause to a vault query. +func sqliteAppendTeamFilter(q string, args []any, teamID *string, teamIDs []string) (string, []any) { + if len(teamIDs) > 0 { + ph := strings.Repeat("?,", len(teamIDs)-1) + "?" + q += " AND (team_id IS NULL OR team_id IN (" + ph + "))" + for _, id := range teamIDs { + args = append(args, id) + } + } else if teamID != nil { + if *teamID != "" { + q += " AND team_id = ?" + args = append(args, *teamID) + } else { + q += " AND team_id IS NULL" + } + } + return q, args +} + // SQLiteVaultStore implements store.VaultStore backed by SQLite. type SQLiteVaultStore struct { db *sql.DB @@ -118,14 +137,7 @@ func (s *SQLiteVaultStore) ListDocuments(ctx context.Context, tenantID, agentID q += " AND agent_id = ?" args = append(args, agentID) } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += " AND team_id = ?" - args = append(args, *opts.TeamID) - } else { - q += " AND team_id IS NULL" - } - } + q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += " AND scope = ?" args = append(args, opts.Scope) @@ -176,14 +188,7 @@ func (s *SQLiteVaultStore) CountDocuments(ctx context.Context, tenantID, agentID q += " AND agent_id = ?" args = append(args, agentID) } - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += " AND team_id = ?" - args = append(args, *opts.TeamID) - } else { - q += " AND team_id IS NULL" - } - } + q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) if opts.Scope != "" { q += " AND scope = ?" args = append(args, opts.Scope) @@ -248,14 +253,7 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt AND (title LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\')` args := []any{opts.TenantID, opts.AgentID, pattern, pattern} - if opts.TeamID != nil { - if *opts.TeamID != "" { - q += " AND team_id = ?" - args = append(args, *opts.TeamID) - } else { - q += " AND team_id IS NULL" - } - } + q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) q += " ORDER BY updated_at DESC LIMIT ?" args = append(args, maxResults*2) diff --git a/internal/store/vault_store.go b/internal/store/vault_store.go index d3f6f6d0..9fd570a1 100644 --- a/internal/store/vault_store.go +++ b/internal/store/vault_store.go @@ -55,6 +55,7 @@ type VaultSearchOptions struct { AgentID string TenantID string TeamID *string // nil = no filter, ptr-to-empty = personal (NULL team_id), ptr-to-uuid = specific team + TeamIDs []string // non-nil = personal (NULL) + these team UUIDs (used for "all accessible" view) Scope string // empty = all scopes DocTypes []string // empty = all types MaxResults int // default 10 @@ -64,6 +65,7 @@ type VaultSearchOptions struct { // VaultListOptions configures a list query for vault documents. type VaultListOptions struct { TeamID *string // nil = no filter, ptr-to-empty = personal (NULL team_id), ptr-to-uuid = specific team + TeamIDs []string // non-nil = personal (NULL) + these team UUIDs (used for "all accessible" view) Scope string // empty = all DocTypes []string // empty = all Limit int diff --git a/ui/web/src/pages/vault/vault-detail-panel.tsx b/ui/web/src/pages/vault/vault-detail-panel.tsx deleted file mode 100644 index 54c4567e..00000000 --- a/ui/web/src/pages/vault/vault-detail-panel.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useState, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { X, Pencil, Plus, FileText, Link2 } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; -import { useVaultLinks, useVaultFileContent, useUpdateDocument, useDeleteDocument } from "./hooks/use-vault"; -import { VaultLinkDialog } from "./vault-link-dialog"; -import { VaultEditControls, DocTypeSelect, ScopeSelect, LinkBadge } from "./vault-detail-edit-section"; -import type { VaultDocument } from "@/types/vault"; - -interface Props { - doc: VaultDocument | null; - open: boolean; - onClose: () => void; - onDeleted?: () => void; -} - -export function VaultDetailPanel({ doc, open, onClose, onDeleted }: Props) { - const { t } = useTranslation("vault"); - const { outlinks, backlinks } = useVaultLinks(doc?.agent_id ?? "", doc?.id ?? null); - const { content, loading: contentLoading, error: contentError } = useVaultFileContent(open && doc ? doc.path : null); - const { update } = useUpdateDocument(doc?.agent_id ?? "", doc?.id ?? ""); - const { remove } = useDeleteDocument(doc?.agent_id ?? "", doc?.id ?? ""); - - const [editMode, setEditMode] = useState(false); - const [editTitle, setEditTitle] = useState(""); - const [editDocType, setEditDocType] = useState(""); - const [editScope, setEditScope] = useState(""); - const [saving, setSaving] = useState(false); - const [confirmDelete, setConfirmDelete] = useState(false); - const [linkDialogOpen, setLinkDialogOpen] = useState(false); - - // Reset edit state when doc changes - useEffect(() => { setEditMode(false); setConfirmDelete(false); }, [doc?.id]); - - // Escape to close - useEffect(() => { - if (!open) return; - const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, [open, onClose]); - - const startEdit = () => { - if (!doc) return; - setEditTitle(doc.title); setEditDocType(doc.doc_type); setEditScope(doc.scope); - setEditMode(true); - }; - - const handleSave = async () => { - setSaving(true); - try { await update({ title: editTitle, doc_type: editDocType, scope: editScope }); setEditMode(false); } - catch { /* toasted */ } - finally { setSaving(false); } - }; - - const handleDelete = async () => { - setSaving(true); - try { await remove(); onDeleted?.(); onClose(); } - catch { /* toasted */ } - finally { setSaving(false); setConfirmDelete(false); } - }; - - if (!doc || !open) return null; - - const hash = doc.content_hash; - const hashDisplay = hash.length > 16 ? `${hash.slice(0, 8)}...${hash.slice(-8)}` : hash; - - return ( - <> -