feat(sprint-4/etapa-5): modelo de privacidad RLS granular + UI de visibilidad

- SQL 010: tablas user_groups y group_memberships con RLS
- SQL 011: tabla process_access para acceso explícito a procesos
- SQL 012: función is_platform_admin() + políticas granulares (select/insert/update/delete) en processes
- types.ts: Process.ownerId (requerido)
- process-repo: fromRow mapea owner_id→ownerId; toRow preserva ownerId en updates; setShared() no-op documentado
- ImportPage: asigna ownerId: user.id al crear proceso
- LibraryPage: toggle Todos/Míos, OwnershipBadge (🔒/👤) en cada tarjeta, opción "Compartir con equipo" en menú de proceso propio con toast de confirmación
- Tests: ownerId: 'test-user' agregado a todos los fixtures de Process

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 22:37:37 -03:00
parent 025393cd43
commit d426791a53
20 changed files with 281 additions and 28 deletions

View File

@@ -18,6 +18,7 @@ export interface Process {
updatedAt: number
groupId: UUID | null // 🆕 Sprint 3 — null = proceso sin grupo asignado ("Sin clasificar")
tags: string[] // 🆕 Sprint 3 — tags libres para clasificación y búsqueda
ownerId: string // 🆕 Sprint 4 — owner del proceso (auth.uid() al crear)
}
export type ActivityType = 'task' | 'subprocess'

View File

@@ -80,6 +80,7 @@ export function ImportPage() {
updatedAt: now,
groupId: targetGroupId,
tags: [],
ownerId: user!.id,
}
const activityElements = extractActivityElements(graph)

View File

@@ -3,10 +3,11 @@ import { useNavigate } from '@tanstack/react-router'
import {
Upload, Search, Plus, Building2, FolderX, GitBranch,
Clock, TrendingUp, BarChart2, FileX2, X, Settings,
MoreVertical, Trash2,
MoreVertical, Trash2, Share2, Lock, User,
} from 'lucide-react'
import { useLibraryStore } from '@/store/library-store'
import { useAuth } from '@/auth/AuthContext'
import { supabaseProcessRepo } from '@/persistence/supabase/process-repo'
import { formatCurrency } from '@/lib/format'
import type { Process, ProcessGroup, Simulation } from '@/domain/types'
import { AppHeader } from '@/components/AppHeader'
@@ -43,6 +44,48 @@ function matchesTags(p: Process, activeTags: string[]): boolean {
return activeTags.every((t) => p.tags.includes(t))
}
// ─── Ownership Badge ──────────────────────────────────────────────────────────
function OwnershipBadge({ process, currentUserId }: { process: Process; currentUserId: string }) {
const isOwn = process.ownerId === currentUserId
return (
<div
className="flex-shrink-0 flex items-center"
title={isOwn ? 'Solo vos' : 'De otro consultor'}
>
{isOwn
? <Lock className="h-3 w-3 text-muted-foreground/60" />
: <User className="h-3 w-3 text-muted-foreground/60" />
}
</div>
)
}
// ─── Toast ────────────────────────────────────────────────────────────────────
function useToast() {
const [message, setMessage] = useState<string | null>(null)
function show(msg: string) {
setMessage(msg)
setTimeout(() => setMessage(null), 2800)
}
return { message, show }
}
function Toast({ message }: { message: string | null }) {
if (!message) return null
return (
<div
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2.5 rounded-lg text-[13px] font-medium text-white shadow-lg transition-all"
style={{ background: '#15803d' }}
>
{message}
</div>
)
}
// ─── KPI Badge ────────────────────────────────────────────────────────────────
function KpiBadge({ process, simulation }: { process: Process; simulation: Simulation | null | undefined }) {
@@ -80,15 +123,18 @@ function KpiBadge({ process, simulation }: { process: Process; simulation: Simul
// ─── Process Card ─────────────────────────────────────────────────────────────
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }: {
function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete, currentUserId, onShared }: {
process: Process
simulation: Simulation | null | undefined
showGroupChip?: boolean
groupName?: string
onDelete?: () => void
currentUserId: string
onShared?: () => void
}) {
const navigate = useNavigate()
const [menuState, setMenuState] = useState<null | 'menu' | 'confirm'>(null)
const isOwn = process.ownerId === currentUserId
// Cierra el menú al hacer click fuera de él
useEffect(() => {
@@ -114,7 +160,10 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }
{/* Info central */}
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
<div className="flex items-center gap-1.5">
<p className="text-[13px] font-medium truncate">{process.name || 'Sin nombre'}</p>
<OwnershipBadge process={process} currentUserId={currentUserId} />
</div>
<div className="flex items-center gap-1 mt-0.5">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="text-[11px] text-muted-foreground">Modificado {relativeDate(process.updatedAt)}</span>
@@ -156,9 +205,23 @@ function ProcessCard({ process, simulation, showGroupChip, groupName, onDelete }
</button>
{menuState === 'menu' && (
<div
className="absolute right-0 top-7 z-20 bg-card border border-border rounded-lg shadow-lg py-1 min-w-[130px]"
className="absolute right-0 top-7 z-20 bg-card border border-border rounded-lg shadow-lg py-1 min-w-[160px]"
onClick={(e) => e.stopPropagation()}
>
{isOwn && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-foreground hover:bg-secondary transition-colors"
onClick={async (e) => {
e.stopPropagation()
await supabaseProcessRepo.setShared(process.id, true)
setMenuState(null)
onShared?.()
}}
>
<Share2 className="h-3.5 w-3.5" />
Compartir con equipo
</button>
)}
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-[12px] text-destructive hover:bg-destructive/10 transition-colors"
onClick={(e) => { e.stopPropagation(); setMenuState('confirm') }}
@@ -308,22 +371,32 @@ function NewGroupCard({ label, onCreate }: { label: string; onCreate: (name: str
function HomeView({
onNavigateToGroup,
onImport,
ownerFilter,
currentUserId,
onShared,
}: {
onNavigateToGroup: (groupId: string | null) => void
onImport: (groupId?: string) => void
ownerFilter: 'all' | 'mine'
currentUserId: string
onShared: () => void
}) {
const { groups, processes, settings, createGroup, isLoading } = useLibraryStore()
const { user } = useAuth()
const [search, setSearch] = useState('')
const [activeTags, setActiveTags] = useState<string[]>([])
const cloud = tagCloudData(processes)
const unclassifiedProcesses = processes.filter((p) => p.groupId == null)
const visibleProcesses = ownerFilter === 'mine'
? processes.filter((p) => p.ownerId === currentUserId)
: processes
const cloud = tagCloudData(visibleProcesses)
const unclassifiedProcesses = visibleProcesses.filter((p) => p.groupId == null)
const isSearching = search.trim().length > 0 || activeTags.length > 0
const filteredProcesses = isSearching
? processes.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
? visibleProcesses.filter((p) => matchesSearch(p, search) && matchesTags(p, activeTags))
: []
function toggleTag(tag: string) {
@@ -420,6 +493,8 @@ function HomeView({
process={p}
showGroupChip
groupName={group?.name}
currentUserId={currentUserId}
onShared={onShared}
/>
)
})}
@@ -475,7 +550,7 @@ function HomeView({
{/* Grid de grupos */}
<div className="grid gap-2.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))' }}>
{groups.map((group) => {
const groupProcesses = processes.filter((p) => p.groupId === group.id)
const groupProcesses = visibleProcesses.filter((p) => p.groupId === group.id)
const lastUpdated = groupProcesses.length > 0
? Math.max(...groupProcesses.map((p) => p.updatedAt))
: null
@@ -521,10 +596,12 @@ function HomeView({
// ─── ProcessCardWithSim (carga simulación lazy) ───────────────────────────────
function ProcessCardWithSim({ process, showGroupChip, groupName }: {
function ProcessCardWithSim({ process, showGroupChip, groupName, currentUserId, onShared }: {
process: Process
showGroupChip?: boolean
groupName?: string
currentUserId: string
onShared: () => void
}) {
const { getLatestSimulation, deleteProcess } = useLibraryStore()
const [simulation, setSimulation] = useState<Simulation | null | undefined>(undefined)
@@ -544,6 +621,8 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
showGroupChip={showGroupChip}
groupName={groupName}
onDelete={() => deleteProcess(process.id)}
currentUserId={currentUserId}
onShared={onShared}
/>
)
}
@@ -553,16 +632,27 @@ function ProcessCardWithSim({ process, showGroupChip, groupName }: {
function GroupView({
groupId,
onBack,
currentUserId,
ownerFilter,
onShared,
}: {
groupId: string | null
onBack: () => void
currentUserId: string
ownerFilter: 'all' | 'mine'
onShared: () => void
}) {
const { groups, processes, settings } = useLibraryStore()
const [search, setSearch] = useState('')
const group = groupId ? groups.find((g) => g.id === groupId) : null
const groupName = group?.name ?? `Procesos sin ${settings.groupLabel.toLowerCase()}`
const groupProcesses = processes
const baseProcesses = ownerFilter === 'mine'
? processes.filter((p) => p.ownerId === currentUserId)
: processes
const groupProcesses = baseProcesses
.filter((p) => groupId === null ? p.groupId == null : p.groupId === groupId)
.filter((p) => matchesSearch(p, search))
@@ -609,7 +699,12 @@ function GroupView({
{/* Lista de procesos */}
<div className="space-y-2">
{groupProcesses.map((p) => (
<ProcessCardWithSim key={p.id} process={p} />
<ProcessCardWithSim
key={p.id}
process={p}
currentUserId={currentUserId}
onShared={onShared}
/>
))}
</div>
</div>
@@ -621,9 +716,12 @@ function GroupView({
export function LibraryPage() {
const navigate = useNavigate()
const { load, processes, groups, settings } = useLibraryStore()
const { user } = useAuth()
const [view, setView] = useState<'home' | 'group'>('home')
const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
const [transitioning, setTransitioning] = useState(false)
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
const { message: toastMessage, show: showToast } = useToast()
useEffect(() => {
load()
@@ -655,7 +753,7 @@ export function LibraryPage() {
}
}
// Preflight fix: contar grupos reales en lugar del clientName heredado
const currentUserId = user!.id
const totalGroups = groups.length
return (
@@ -671,6 +769,30 @@ export function LibraryPage() {
</p>
</div>
<div className="flex items-center gap-2">
{/* Toggle Todos / Míos */}
<div className="flex rounded-lg border border-border overflow-hidden text-[12px]">
<button
className="px-3 py-1.5 font-medium transition-colors"
style={ownerFilter === 'all'
? { background: '#F59845', color: 'white' }
: { background: 'transparent', color: 'hsl(var(--muted-foreground))' }
}
onClick={() => setOwnerFilter('all')}
>
Todos
</button>
<button
className="px-3 py-1.5 font-medium transition-colors border-l border-border"
style={ownerFilter === 'mine'
? { background: '#F59845', color: 'white' }
: { background: 'transparent', color: 'hsl(var(--muted-foreground))' }
}
onClick={() => setOwnerFilter('mine')}
>
Míos
</button>
</div>
<button
onClick={() => navigate({ to: '/settings' })}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
@@ -698,15 +820,23 @@ export function LibraryPage() {
<HomeView
onNavigateToGroup={navigateToGroup}
onImport={handleImport}
ownerFilter={ownerFilter}
currentUserId={currentUserId}
onShared={() => showToast('Proceso compartido con el equipo')}
/>
) : (
<GroupView
groupId={activeGroupId}
onBack={navigateHome}
currentUserId={currentUserId}
ownerFilter={ownerFilter}
onShared={() => showToast('Proceso compartido con el equipo')}
/>
)}
</div>
</div>
<Toast message={toastMessage} />
</div>
)
}

View File

@@ -14,7 +14,8 @@ function toRow(process: Process, userId: string) {
automation_investment: process.automationInvestment,
group_id: process.groupId,
tags: process.tags,
owner_id: userId,
// owner_id se preserva del valor existente en el proceso; solo se inicializa con userId en creates
owner_id: process.ownerId || userId,
created_by: userId,
updated_by: userId,
updated_at: new Date().toISOString(),
@@ -36,6 +37,7 @@ function fromRow(row: Record<string, unknown>): Process {
tags: Array.isArray(row.tags) ? (row.tags as string[]) : [],
createdAt: new Date(row.created_at as string).getTime(),
updatedAt: new Date(row.updated_at as string).getTime(),
ownerId: (row.owner_id as string) ?? '',
}
}
@@ -69,4 +71,10 @@ export const supabaseProcessRepo = {
const { error } = await supabase.from('processes').delete().eq('id', id)
if (error) throw error
},
// No-op en Sprint 4: la implementación real (insertar en process_access) llega cuando existan guests.
// El método existe para que la UI de "Compartir con equipo" funcione sin escritura a DB.
async setShared(_processId: string, _shared: boolean): Promise<void> {
return
},
}