diff --git a/sprints/sprint-7/ETAPA_1_PROMPT.md b/sprints/sprint-7/ETAPA_1_PROMPT.md new file mode 100644 index 0000000..7607a03 --- /dev/null +++ b/sprints/sprint-7/ETAPA_1_PROMPT.md @@ -0,0 +1,430 @@ +# Etapa 1 — Migraciones de base de datos + SECURITY DEFINER helpers + +**Sprint:** 7 +**Fecha:** 2026-06-24 +**Alcance:** solo archivos SQL en `supabase/migrations/`. Cero cambios en frontend. + +--- + +## Contexto + +Esta etapa crea la fundación de datos sobre la que se construye todo el multi-usuario de Sprint 7. El esquema replica el patrón de InQ Sizing documentado en `cowork/InQ ROI - Simulador de procesos/autenticacion-y-usuarios.md` — leerlo antes de empezar. + +**Schema actual relevante (no tocar lo que ya existe):** +- `public.users`: `id`, `name`, `avatar_url`, `platform_role` ('platform_admin'|'member'|'guest'), `company`, `org_id` (no existe aún), `created_at` +- `public.processes`: `client` (text — campo de display, se mantiene), `owner_id`, más campos de config. Sin `org_id` aún. +- `public.user_groups` + `group_memberships` + `public.process_access`: sistema de acceso interno de InQuality — NO tocar +- `public.is_platform_admin()`: función SECURITY DEFINER existente — NO tocar + +--- + +## Archivo 1: `supabase/migrations/014_create_organizations.sql` + +```sql +-- Tabla de organizaciones clientes +-- is_provider = true identifica a InQuality (org administradora de la plataforma) +CREATE TABLE IF NOT EXISTS public.organizations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + is_provider boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY; + +-- Insertar la organización de InQuality como proveedor +-- ON CONFLICT DO NOTHING: idempotente si ya existe +INSERT INTO public.organizations (name, is_provider) +VALUES ('InQuality', true) +ON CONFLICT DO NOTHING; + +-- Agregar org_id a users (nullable — usuarios InQuality existentes no tienen org de cliente) +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL; + +-- Agregar org_id a processes (nullable — procesos existentes se vinculan por migración) +ALTER TABLE public.processes + ADD COLUMN IF NOT EXISTS org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL; + +-- Ampliar check constraint de platform_role para incluir roles de cliente +-- Primero eliminar la restricción existente, luego recrear con los valores nuevos +ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_platform_role_check; +ALTER TABLE public.users ADD CONSTRAINT users_platform_role_check + CHECK (platform_role IN ('platform_admin', 'member', 'client_editor', 'client_viewer', 'guest')); + +-- ── Migración automática: crear orgs desde valores únicos de processes.client ── +-- Crea una organización por cada client name único y no vacío +-- ON CONFLICT DO NOTHING: si ya existe una org con ese nombre, no duplicar +INSERT INTO public.organizations (name, is_provider) +SELECT DISTINCT trim(client), false +FROM public.processes +WHERE client IS NOT NULL AND trim(client) != '' +ON CONFLICT (name) DO NOTHING; + +-- Vincular procesos a sus orgs recién creadas +UPDATE public.processes p +SET org_id = o.id +FROM public.organizations o +WHERE trim(o.name) = trim(p.client) + AND o.is_provider = false + AND p.org_id IS NULL; + +-- Asignar org_id de InQuality a usuarios InQuality existentes +-- (los que tienen email @inquality.com.py o platform_role = 'platform_admin'/'member') +UPDATE public.users u +SET org_id = (SELECT id FROM public.organizations WHERE is_provider = true LIMIT 1) +WHERE u.org_id IS NULL + AND u.platform_role IN ('platform_admin', 'member'); +``` + +**Nota sobre el ON CONFLICT en organizations:** la tabla no tiene un UNIQUE constraint en `name` aún. Agregar uno: + +```sql +-- Agregar constraint UNIQUE en name para que ON CONFLICT funcione +ALTER TABLE public.organizations ADD CONSTRAINT organizations_name_unique UNIQUE (name); +``` + +Este constraint va ANTES del INSERT de InQuality y de la migración automática. + +--- + +## Archivo 2: `supabase/migrations/015_client_roles_and_rls.sql` + +```sql +-- ── SECURITY DEFINER helpers ────────────────────────────────────────────────── + +-- Retorna el org_id del usuario actual (null para usuarios sin org asignada) +CREATE OR REPLACE FUNCTION public.current_org() +RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER AS $$ + SELECT org_id FROM public.users WHERE id = auth.uid() +$$; + +-- Retorna true si el usuario pertenece a la organización proveedora (InQuality) +CREATE OR REPLACE FUNCTION public.is_inquality() +RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.users u + JOIN public.organizations o ON o.id = u.org_id + WHERE u.id = auth.uid() AND o.is_provider = true + ) +$$; + +-- Retorna el platform_role del usuario actual +CREATE OR REPLACE FUNCTION public.current_platform_role() +RETURNS text LANGUAGE sql STABLE SECURITY DEFINER AS $$ + SELECT platform_role FROM public.users WHERE id = auth.uid() +$$; + +-- ── Trigger handle_new_user ─────────────────────────────────────────────────── +-- Auto-crea fila en public.users para usuarios @inquality.com.py que se loguean +-- con Google OAuth. Los usuarios clientes se crean via Edge Function (invite), +-- no por este trigger. +-- ON CONFLICT (id) DO NOTHING: no sobrescribir si la Edge Function ya creó la fila. + +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + provider_org_id uuid; +BEGIN + IF new.email LIKE '%@inquality.com.py' THEN + SELECT id INTO provider_org_id + FROM public.organizations + WHERE is_provider = true + LIMIT 1; + + INSERT INTO public.users (id, name, avatar_url, platform_role, org_id) + VALUES ( + new.id, + COALESCE(new.raw_user_meta_data->>'full_name', new.email), + new.raw_user_meta_data->>'avatar_url', + 'member', + provider_org_id + ) + ON CONFLICT (id) DO NOTHING; + END IF; + RETURN new; +END; +$$; + +-- Crear trigger (DROP IF EXISTS para idempotencia) +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user(); + +-- ── RLS: organizations ──────────────────────────────────────────────────────── + +CREATE POLICY "orgs_select" ON public.organizations + FOR SELECT USING ( + public.is_inquality() + OR id = public.current_org() + ); + +CREATE POLICY "orgs_insert" ON public.organizations + FOR INSERT WITH CHECK (public.is_platform_admin()); + +CREATE POLICY "orgs_update" ON public.organizations + FOR UPDATE USING (public.is_platform_admin()); + +CREATE POLICY "orgs_delete" ON public.organizations + FOR DELETE USING (public.is_platform_admin()); + +-- ── RLS: users ─────────────────────────────────────────────────────────────── +-- Reemplazar la política permisiva existente + +DROP POLICY IF EXISTS "read_users" ON public.users; + +CREATE POLICY "users_select_own" ON public.users + FOR SELECT USING (id = auth.uid()); + +CREATE POLICY "users_select_admin" ON public.users + FOR SELECT USING (public.is_platform_admin()); + +-- ── RLS: processes ──────────────────────────────────────────────────────────── +-- Reemplazar select y update para incluir acceso por org de cliente + +DROP POLICY IF EXISTS "select_processes" ON public.processes; + +CREATE POLICY "select_processes" ON public.processes + FOR SELECT USING ( + public.is_platform_admin() + OR owner_id = auth.uid() + OR ( + public.current_org() IS NOT NULL + AND org_id = public.current_org() + ) + OR EXISTS ( + SELECT 1 FROM public.process_access + WHERE process_id = processes.id + AND ( + (grantee_type = 'user' AND grantee_id = auth.uid()) + OR (grantee_type = 'group' AND grantee_id IN ( + SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid() + )) + ) + ) + ); + +DROP POLICY IF EXISTS "insert_processes" ON public.processes; + +CREATE POLICY "insert_processes" ON public.processes + FOR INSERT WITH CHECK ( + public.is_platform_admin() + OR public.current_platform_role() = 'member' + -- client_editor y client_viewer NO pueden crear procesos nuevos + ); + +DROP POLICY IF EXISTS "update_processes" ON public.processes; + +CREATE POLICY "update_processes" ON public.processes + FOR UPDATE USING ( + public.is_platform_admin() + OR owner_id = auth.uid() + OR ( + public.current_platform_role() = 'client_editor' + AND org_id = public.current_org() + ) + OR EXISTS ( + SELECT 1 FROM public.process_access + WHERE process_id = processes.id AND permission = 'edit' + AND ( + (grantee_type = 'user' AND grantee_id = auth.uid()) + OR (grantee_type = 'group' AND grantee_id IN ( + SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid() + )) + ) + ) + ); + +-- ── RLS: activities ────────────────────────────────────────────────────────── +-- Reemplazar política permisiva "all_activities" por filtro via proceso padre + +DROP POLICY IF EXISTS "all_activities" ON public.activities; + +-- SELECT: si el usuario puede ver el proceso padre, puede ver sus actividades +CREATE POLICY "activities_select" ON public.activities + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id) + ); + +-- INSERT / UPDATE: InQuality + client_editor (no client_viewer) +CREATE POLICY "activities_insert" ON public.activities + FOR INSERT WITH CHECK ( + EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id) + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); + +CREATE POLICY "activities_update" ON public.activities + FOR UPDATE USING ( + EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id) + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); + +-- DELETE: solo InQuality +CREATE POLICY "activities_delete" ON public.activities + FOR DELETE USING ( + EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id) + AND public.current_platform_role() IN ('platform_admin', 'member') + ); + +-- ── RLS: resources ──────────────────────────────────────────────────────────── + +DROP POLICY IF EXISTS "all_resources" ON public.resources; + +CREATE POLICY "resources_select" ON public.resources + FOR SELECT USING ( + public.is_inquality() + OR ( + process_id IS NULL -- recursos del catálogo global: visibles a todos autenticados + AND auth.uid() IS NOT NULL + ) + OR EXISTS (SELECT 1 FROM public.processes WHERE id = resources.process_id) + ); + +CREATE POLICY "resources_insert" ON public.resources + FOR INSERT WITH CHECK ( + auth.uid() IS NOT NULL + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); + +CREATE POLICY "resources_update" ON public.resources + FOR UPDATE USING ( + auth.uid() IS NOT NULL + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); + +CREATE POLICY "resources_delete" ON public.resources + FOR DELETE USING ( + public.current_platform_role() IN ('platform_admin', 'member') + ); + +-- ── RLS: simulations ───────────────────────────────────────────────────────── + +DROP POLICY IF EXISTS "all_simulations" ON public.simulations; + +CREATE POLICY "simulations_select" ON public.simulations + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id) + ); + +CREATE POLICY "simulations_insert" ON public.simulations + FOR INSERT WITH CHECK ( + EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id) + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); + +CREATE POLICY "simulations_delete" ON public.simulations + FOR DELETE USING ( + EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id) + AND public.current_platform_role() IN ('platform_admin', 'member') + ); + +-- ── RLS: activity_resource_assignments ─────────────────────────────────────── +-- Heredan acceso del proceso via actividad + +DROP POLICY IF EXISTS "all_activity_resource_assignments" ON public.activity_resource_assignments; + +CREATE POLICY "ara_select" ON public.activity_resource_assignments + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM public.activities a + JOIN public.processes p ON p.id = a.process_id + WHERE a.id = activity_resource_assignments.activity_id + ) + ); + +CREATE POLICY "ara_write" ON public.activity_resource_assignments + FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM public.activities a + JOIN public.processes p ON p.id = a.process_id + WHERE a.id = activity_resource_assignments.activity_id + ) + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); + +CREATE POLICY "ara_update" ON public.activity_resource_assignments + FOR UPDATE USING ( + EXISTS ( + SELECT 1 FROM public.activities a + JOIN public.processes p ON p.id = a.process_id + WHERE a.id = activity_resource_assignments.activity_id + ) + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); + +CREATE POLICY "ara_delete" ON public.activity_resource_assignments + FOR DELETE USING ( + EXISTS ( + SELECT 1 FROM public.activities a + JOIN public.processes p ON p.id = a.process_id + WHERE a.id = activity_resource_assignments.activity_id + ) + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); + +-- ── RLS: gateways ──────────────────────────────────────────────────────────── + +DROP POLICY IF EXISTS "all_gateways" ON public.gateways; + +CREATE POLICY "gateways_select" ON public.gateways + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id) + ); + +CREATE POLICY "gateways_write" ON public.gateways + FOR INSERT WITH CHECK ( + EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id) + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); + +CREATE POLICY "gateways_update" ON public.gateways + FOR UPDATE USING ( + EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id) + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); +``` + +--- + +## Qué verificar antes de aplicar en producción + +1. **En Supabase Studio (SQL Editor):** ejecutar primero en una rama o en el proyecto de staging si existe. Las migraciones son destructivas en las policies RLS existentes (DROP POLICY). + +2. **Verificar nombres exactos de las policies a eliminar:** los `DROP POLICY IF EXISTS` son seguros (no fallan si no existen), pero verificar que los nombres coincidan con los de las migraciones anteriores. + +3. **Verificar tabla `activity_resource_assignments`:** confirmar que existe con ese nombre exacto (puede ser `activity_resource_assignments` o similar según migration 007). + +4. **Verificar tabla `gateways`:** confirmar que existe y tiene política existente que eliminar. + +--- + +## Criterios de validación + +- [ ] Migration 014 aplica sin error en Supabase Studio +- [ ] Migration 015 aplica sin error +- [ ] `public.organizations` existe con fila InQuality (`is_provider = true`) +- [ ] Procesos con `client` no vacío tienen `org_id` asignado (verificar en tabla) +- [ ] Usuarios existentes de InQuality tienen `org_id` apuntando a la org de InQuality +- [ ] Check constraint acepta 'client_editor' y 'client_viewer' (test: UPDATE manual de un usuario) +- [ ] `SELECT public.current_org()` retorna el `org_id` del usuario autenticado +- [ ] `SELECT public.is_inquality()` retorna true para usuario InQuality, false para usuario sin org +- [ ] `SELECT public.current_platform_role()` retorna el rol correcto +- [ ] Trigger `on_auth_user_created` existe en `auth.users` +- [ ] Un usuario con `platform_role = 'client_viewer'` NO puede INSERT en activities (verificar con RLS test en Studio) +- [ ] `npm run test` → 552+ tests verdes (los tests de dominio/UI no tocan DB directamente) +- [ ] `npm run build` limpio (ningún cambio de TypeScript en esta etapa) + +--- + +## Archivos a crear + +- `supabase/migrations/014_create_organizations.sql` +- `supabase/migrations/015_client_roles_and_rls.sql` + +## NO modificar + +- Ningún archivo TypeScript / React +- Ningún archivo de tests existente +- `supabase/migrations/001` a `013` — son historia inmutable +- `public.user_groups`, `public.group_memberships`, `public.process_access` — sistema interno de InQuality, no tocar sus políticas diff --git a/sprints/sprint-7/ETAPA_2_PROMPT.md b/sprints/sprint-7/ETAPA_2_PROMPT.md new file mode 100644 index 0000000..f388fef --- /dev/null +++ b/sprints/sprint-7/ETAPA_2_PROMPT.md @@ -0,0 +1,273 @@ +# Etapa 2 — Edge Function: invitación de usuarios cliente + +**Sprint:** 7 +**Fecha:** 2026-07-06 +**Alcance:** Edge Function de Supabase + endpoint de revocación. Cero cambios en frontend. Cero cambios en migraciones existentes (001-015 son historia inmutable). + +--- + +## Contexto + +Las migraciones 014-015 ya están aplicadas en producción: +- `public.organizations` existe con InQuality (is_provider=true) +- `public.users.org_id` + `public.users.platform_role` con 4 roles: platform_admin, member, client_editor, client_viewer +- SECURITY DEFINER helpers: `current_org()`, `is_inquality()`, `current_platform_role()` +- RLS activo en todas las tablas + +Esta etapa crea el flujo de invitación: un `platform_admin` llama a una Edge Function que: +1. Crea el usuario en `auth.users` de Supabase (vía Admin API) +2. Crea o reutiliza la organización cliente +3. Inserta la fila en `public.users` con el rol correcto y org_id +4. Envía el email de bienvenida vía Supabase Auth inviteUserByEmail + +El patrón es el mismo que InQ Sizing (`autenticacion-y-usuarios.md`), adaptado al schema de InQ ROI. + +--- + +## Estructura de archivos a crear + +``` +supabase/ +└── functions/ + ├── invite-user/ + │ └── index.ts ← Edge Function principal + └── revoke-user/ + └── index.ts ← Edge Function de revocación/suspensión +``` + +--- + +## Edge Function: `invite-user` + +### Request + +```typescript +// POST /functions/v1/invite-user +// Authorization: Bearer +{ + email: string, // email del usuario a invitar + name: string, // nombre completo + platform_role: 'client_editor' | 'client_viewer' | 'member', + org_name: string, // nombre de la organización (se crea si no existe) + // Si platform_role = 'member', org_name se ignora → org será InQuality +} +``` + +### Validaciones + +- Solo `platform_admin` puede llamar esta función. Verificar con `adminClient.auth.getUser(token)` + consulta a `public.users` para confirmar `platform_role = 'platform_admin'`. +- `email` requerido, formato válido. +- `platform_role` debe ser uno de los tres valores permitidos. +- `org_name` requerido si `platform_role` es `client_editor` o `client_viewer`. +- No invitar emails que ya existen en `auth.users` (verificar antes). + +### Lógica + +```typescript +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' + +const adminClient = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! +) + +// 1. Autenticar al llamador +const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '') +const { data: { user: caller } } = await adminClient.auth.getUser(callerToken) +const { data: callerProfile } = await adminClient + .from('users') + .select('platform_role') + .eq('id', caller.id) + .single() + +if (callerProfile?.platform_role !== 'platform_admin') { + return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 }) +} + +// 2. Resolver org_id +let orgId: string + +if (platform_role === 'member') { + // Nuevo miembro InQuality → org es InQuality + const { data: inqOrg } = await adminClient + .from('organizations') + .select('id') + .eq('is_provider', true) + .single() + orgId = inqOrg.id +} else { + // Usuario cliente → buscar o crear org por nombre + const { data: existingOrg } = await adminClient + .from('organizations') + .select('id') + .eq('name', org_name.trim()) + .single() + + if (existingOrg) { + orgId = existingOrg.id + } else { + const { data: newOrg } = await adminClient + .from('organizations') + .insert({ name: org_name.trim(), is_provider: false }) + .select('id') + .single() + orgId = newOrg.id + } +} + +// 3. Invitar usuario vía Auth Admin API (envía email automáticamente) +const { data: inviteData, error: inviteError } = await adminClient.auth.admin.inviteUserByEmail( + email, + { + data: { full_name: name }, // raw_user_meta_data + redirectTo: `${Deno.env.get('SITE_URL')}/auth/callback` + } +) + +if (inviteError) { + return new Response(JSON.stringify({ error: inviteError.message }), { status: 400 }) +} + +// 4. Crear fila en public.users +// UPSERT (no INSERT) para manejar race condition: si el trigger handle_new_user +// ya corrió (emails @inquality.com.py), no duplicar. +const { error: upsertError } = await adminClient + .from('users') + .upsert({ + id: inviteData.user.id, + name, + platform_role, + org_id: orgId, + }, { onConflict: 'id', ignoreDuplicates: false }) + +if (upsertError) { + return new Response(JSON.stringify({ error: upsertError.message }), { status: 500 }) +} + +return new Response(JSON.stringify({ success: true, userId: inviteData.user.id }), { status: 200 }) +``` + +### Headers de respuesta + +Siempre incluir: +```typescript +const headers = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': Deno.env.get('SITE_URL') ?? '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} +``` + +Manejar OPTIONS (CORS preflight): +```typescript +if (req.method === 'OPTIONS') { + return new Response('ok', { headers }) +} +``` + +--- + +## Edge Function: `revoke-user` + +### Request + +```typescript +// POST /functions/v1/revoke-user +// Authorization: Bearer +{ + userId: string // UUID del usuario a revocar +} +``` + +### Lógica + +```typescript +// 1. Verificar que el llamador es platform_admin (igual que invite-user) + +// 2. No permitir auto-revocación +if (userId === caller.id) { + return new Response(JSON.stringify({ error: 'No puedes revocar tu propio acceso' }), { status: 400 }) +} + +// 3. Cambiar platform_role a 'guest' (suspensión suave, no elimina datos) +const { error } = await adminClient + .from('users') + .update({ platform_role: 'guest' }) + .eq('id', userId) + +if (error) { + return new Response(JSON.stringify({ error: error.message }), { status: 500 }) +} + +// 4. Opcionalmente: revocar sesiones activas +await adminClient.auth.admin.signOut(userId) + +return new Response(JSON.stringify({ success: true }), { status: 200 }) +``` + +--- + +## Variables de entorno requeridas + +Las Edge Functions de Supabase tienen acceso automático a: +- `SUPABASE_URL` +- `SUPABASE_SERVICE_ROLE_KEY` +- `SUPABASE_ANON_KEY` + +Agregar manualmente en Supabase Dashboard → Edge Functions → Secrets: +- `SITE_URL` = `https://inq-roi.inqualityhq.com` + +Documentar esto en un comentario al tope de cada función. + +--- + +## TypeScript — tipado estricto + +```typescript +interface InviteUserRequest { + email: string + name: string + platform_role: 'client_editor' | 'client_viewer' | 'member' + org_name?: string +} + +interface RevokeUserRequest { + userId: string +} +``` + +Validar con guardas de tipo antes de proceder. Retornar errores descriptivos en español para consumo interno. + +--- + +## Criterios de validación + +- [ ] `supabase/functions/invite-user/index.ts` existe y compila (Deno/TypeScript) +- [ ] `supabase/functions/revoke-user/index.ts` existe y compila +- [ ] Llamada sin Authorization header → 401 +- [ ] Llamada con token de usuario no-admin → 403 +- [ ] Llamada con email ya existente → error descriptivo (no crash) +- [ ] Llamada con platform_role inválido → error descriptivo +- [ ] `npm run test` → 552+ tests verdes (ningún test nuevo requerido en esta etapa — el E2E de invite requiere despliegue real) +- [ ] `npm run build` limpio + +## Documentar en comentario al tope de cada función + +```typescript +/** + * Edge Function: invite-user + * Propósito: invitar usuario externo (client_editor/client_viewer) o interno (member) + * Solo accesible por platform_admin. + * Variables de entorno requeridas: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SITE_URL + * Deploy: supabase functions deploy invite-user + */ +``` + +--- + +## NO modificar + +- Ningún archivo de migración (001-015 son historia inmutable) +- Ningún archivo TypeScript/React del frontend +- Ningún test existente +- Las tablas `user_groups`, `group_memberships`, `process_access` y sus policies diff --git a/sprints/sprint-7/ETAPA_3_PROMPT.md b/sprints/sprint-7/ETAPA_3_PROMPT.md new file mode 100644 index 0000000..f798345 --- /dev/null +++ b/sprints/sprint-7/ETAPA_3_PROMPT.md @@ -0,0 +1,293 @@ +# Etapa 3 — Capa de auth: fix de rol desde DB + email/password + reset-password + +**Sprint:** 7 +**Fecha:** 2026-07-06 +**Alcance:** capa de auth (frontend + router). Sin cambios en Edge Functions ni migraciones. + +--- + +## Contexto y problema a resolver + +`SupabaseAuthService.buildAuthUser()` infiere el rol del email: `@inquality.com.py` → `platform_admin`, cualquier otro → `guest`. Esto rompe los nuevos roles: un `client_editor` con email `@banco-solar.com` siempre sería `guest` y no podría acceder a nada. + +El fix: `syncProfileFromDB` (o equivalente) debe leer `platform_role` y `org_id` desde `public.users` y actualizar el estado del usuario. La heurística del email puede sobrevivir como valor inicial optimista, pero el rol real viene de la DB. + +**ANTES de modificar cualquier archivo:** leer el código actual de: +- `src/features/auth/SupabaseAuthService.ts` (o donde viva `buildAuthUser`, `syncProfileFromDB`, `signInWithGoogle`) +- `src/features/auth/AuthContext.tsx` (o `AuthStore.tsx`) +- `src/domain/types.ts` (para ver la interface `AuthUser` actual) +- `src/router.tsx` (para entender el patrón de rutas existente) + +Adaptar el fix según lo que realmente existe — los nombres de métodos y archivos pueden diferir del BRIEF. + +--- + +## 3a — Fix crítico: leer `platform_role` y `org_id` desde DB + +### Cambios en `src/domain/types.ts` + +Extender `AuthUser` para incluir `orgId`: + +```typescript +export interface AuthUser { + id: string + email: string + name: string + avatarUrl?: string + platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest' + company?: string + orgId?: string // uuid de la organización del usuario — null para usuarios sin org +} +``` + +Si `AuthUser` ya existe con campos distintos, no eliminar campos existentes — solo agregar `orgId`. + +### Cambios en `SupabaseAuthService` (o donde viva `syncProfileFromDB`) + +Extender el SELECT para incluir `platform_role` y `org_id`: + +```typescript +const { data } = await supabase + .from('users') + .select('name, company, platform_role, org_id') + .eq('id', userId) + .maybeSingle() + +if (data) { + // Retornar o despachar con los nuevos campos + return { + name: data.name, + company: data.company ?? undefined, + platformRole: data.platform_role as AuthUser['platformRole'], + orgId: data.org_id ?? undefined, + } +} +``` + +### Cambios en `AuthContext` (o donde se llame `syncProfileFromDB`) + +Cuando se recibe el resultado de `syncProfileFromDB`, actualizar TODOS los campos incluyendo `platformRole` y `orgId`: + +```typescript +setUser(prev => prev + ? { + ...prev, + name: profile.name || prev.name, + company: profile.company, + platformRole: profile.platformRole, // ← reemplaza la heurística de email + orgId: profile.orgId, + } + : prev +) +``` + +### Mantener heurística de email como valor inicial (optimista) + +`buildAuthUser()` puede seguir usando el email para un valor inicial mientras se carga el perfil de DB. El `syncProfileFromDB` lo corrige después. Esto evita el flash de "sin acceso" durante la carga inicial. + +--- + +## 3b — Agregar `signInWithEmailPassword` a la capa de auth + +### Interface `AuthService` (o equivalente) + +Agregar método: + +```typescript +signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> +``` + +### Implementación en `SupabaseAuthService` + +```typescript +async signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> { + const { error } = await supabase.auth.signInWithPassword({ email, password }) + return { error: error?.message ?? null } +} +``` + +### Exponer en el contexto de auth + +Si hay un hook `useAuth()` o similar, agregar `signInWithEmailPassword` al objeto retornado. Si no, exponerlo directamente desde el contexto. + +--- + +## 3c — Guard TOKEN_REFRESHED en `onAuthStateChange` + +Modificar el listener de `onAuthStateChange` para no disparar loading innecesariamente en TOKEN_REFRESHED: + +```typescript +supabase.auth.onAuthStateChange(async (event, session) => { + if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') { + // No cambiar loading ni disparar sync — la sesión ya está activa + return + } + + if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') { + // ... lógica de login existente + } else if (event === 'SIGNED_OUT') { + setUser(null) + setLoading(false) + } +}) +``` + +**Nota:** este fix puede resolver el bug de "workspace loading — se queda cargando sin refresh" reportado en Sprint 5 si ese bug se manifestaba al volver a la tab. Verificar si es el caso después de aplicar. + +--- + +## 3d — LoginPage: agregar form email/password + +La `LoginPage` actual solo tiene el botón "Continuar con Google". Agregar un formulario debajo: + +### Layout + +``` +[ Continuar con Google ] + +─────── o ───────────────── + +[ Email ] +[ Contraseña ] +[ Ingresar ] + +Hint: Los usuarios externos acceden por invitación. + Si no recibiste uno, contactá a tu consultor InQuality. +``` + +### Validación (Zod) + +```typescript +const loginSchema = z.object({ + email: z.string().email('Email inválido'), + password: z.string().min(1, 'La contraseña es requerida'), +}) +``` + +### Comportamiento + +- `type="submit"` en el form, no `type="button"` — para que Enter funcione +- Mostrar error inline debajo del form si `signInWithEmailPassword` retorna error (no toast, no alert) +- Spinner o estado disabled en el botón mientras se procesa +- En caso de error: el mensaje de Supabase en inglés es aceptable, o traducir si es simple ("Invalid login credentials" → "Email o contraseña incorrectos") +- No registrar usuarios nuevos — solo login. Sin link "Crear cuenta". + +### Estilo + +Usar los componentes shadcn/ui existentes: `Input`, `Button`, `Label`. El separador "o" puede ser un `` con texto superpuesto o simplemente un `

` centrado con estilo. No introducir librerías nuevas. + +--- + +## 3e — Página `/reset-password` (set-password post-invite) + +Cuando un usuario acepta el magic link de invitación, Supabase redirige a `{SITE_URL}/auth/callback` y luego a una URL con `access_token` en el hash. La app debe capturar esto y llevarlo a una página donde el usuario setea su contraseña. + +### Nueva ruta + +Agregar `/reset-password` en `src/router.tsx`. Esta ruta es pública (no requiere sesión activa). + +### Nuevo componente `src/features/auth/ResetPasswordPage.tsx` + +```typescript +// Leer el token del hash de la URL +// Supabase lo procesa automáticamente vía onAuthStateChange con event = PASSWORD_RECOVERY + +// El flujo: +// 1. El usuario hace click en el link del email +// 2. Supabase procesa el token → onAuthStateChange dispara PASSWORD_RECOVERY con sesión +// 3. La app detecta PASSWORD_RECOVERY y redirige a /reset-password +// 4. ResetPasswordPage muestra el form de nueva contraseña + +// En onAuthStateChange (en AuthContext): +if (event === 'PASSWORD_RECOVERY') { + navigate({ to: '/reset-password' }) + return +} +``` + +```typescript +// ResetPasswordPage.tsx +const ResetPasswordPage = () => { + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const navigate = useNavigate() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (password !== confirm) { + setError('Las contraseñas no coinciden') + return + } + if (password.length < 8) { + setError('La contraseña debe tener al menos 8 caracteres') + return + } + setLoading(true) + const { error } = await supabase.auth.updateUser({ password }) + if (error) { + setError(error.message) + setLoading(false) + } else { + navigate({ to: '/' }) + } + } + + return ( + // Form con dos campos: nueva contraseña + confirmar, botón "Establecer contraseña" + // Misma estructura visual que LoginPage + ) +} +``` + +### Agregar `PASSWORD_RECOVERY` al guard de `onAuthStateChange` + +```typescript +if (event === 'PASSWORD_RECOVERY') { + // Sesión activa pero el usuario necesita setear contraseña + // Redirigir a /reset-password sin setear el user en estado + navigate({ to: '/reset-password' }) + return +} +``` + +--- + +## Tests nuevos (mínimo requerido) + +```typescript +// tests/features/auth/auth-role-sync.test.ts +// - buildAuthUser con email @inquality.com.py retorna platformRole optimista +// - syncProfileFromDB con { platform_role: 'client_editor' } actualiza el estado +// - syncProfileFromDB con { platform_role: 'platform_admin' } (DB) sobrescribe heurística de email +// - AuthUser con orgId seteado retiene el campo tras sync + +// NO requiere tests E2E en esta etapa — el login real se prueba manualmente +// con un usuario invitado en producción +``` + +--- + +## Criterios de validación + +- [ ] `AuthUser` tiene el campo `orgId?: string` +- [ ] `syncProfileFromDB` hace SELECT con `platform_role, org_id` +- [ ] Después del sync, `user.platformRole` refleja el valor de DB (no la heurística de email) +- [ ] `signInWithEmailPassword` existe en la capa de auth y funciona con Supabase +- [ ] `LoginPage` muestra el form email/password separado del botón Google +- [ ] `/reset-password` existe como ruta pública, muestra el form de nueva contraseña +- [ ] `TOKEN_REFRESHED` en `onAuthStateChange` no dispara `setLoading(true)` +- [ ] `PASSWORD_RECOVERY` en `onAuthStateChange` redirige a `/reset-password` +- [ ] `npm run test` → 552+ tests verdes (más los nuevos de auth-role-sync) +- [ ] `npm run build` limpio + +--- + +## NO modificar + +- Edge Functions (supabase/functions/) +- Archivos de migración (001-015) +- WorkspacePage, ReportPage, LibraryPage — los cambios de UI de roles van en Etapa 5 +- Panel de admin — va en Etapa 4 +- Lógica de simulación, dominio, y cálculo de ROI diff --git a/sprints/sprint-7/ETAPA_4_PROMPT.md b/sprints/sprint-7/ETAPA_4_PROMPT.md new file mode 100644 index 0000000..004c485 --- /dev/null +++ b/sprints/sprint-7/ETAPA_4_PROMPT.md @@ -0,0 +1,288 @@ +# Etapa 4 — Panel de administración de InQuality + +**Sprint:** 7 +**Fecha:** 2026-07-06 +**Alcance:** nuevas rutas `/admin/*` con guard de rol + componentes de admin. Sin cambios en migraciones, Edge Functions, WorkspacePage, ReportPage, o LibraryPage. + +--- + +## Contexto + +Las Edge Functions `invite-user` y `revoke-user` ya están desplegadas (Etapa 2). Esta etapa construye la UI que las consume. El acceso es exclusivo para `platform_admin`. + +**ANTES de empezar:** leer `src/router.tsx` para entender el patrón de rutas y guards existentes (`beforeLoad`). La estructura de archivos debe seguir el patrón de `features/` ya establecido. + +--- + +## Nuevas rutas + +``` +/admin → redirect a /admin/organizations +/admin/organizations → lista de orgs clientes +/admin/organizations/$orgId → detalle de org (procesos + usuarios) +/admin/users → lista de todos los usuarios +``` + +Todas requieren `platform_role === 'platform_admin'` — si no, redirect a `/`. + +### Guard en TanStack Router (patrón `beforeLoad`) + +```typescript +// Crear layout route para /admin que incluya el guard: +beforeLoad: ({ context }) => { + const user = context.auth?.user + if (!user || user.platformRole !== 'platform_admin') { + throw redirect({ to: '/' }) + } +} +``` + +--- + +## Layout de admin + +**No crear un layout nuevo de toda la app.** Usar el `AppHeader` existente. Las páginas de admin son pages normales con su propia estructura interna. + +Estructura interna sugerida para cada page de admin: + +``` +

+ {/* Nav tabs entre Organizations y Users */} +
+ Organizaciones + Usuarios +
+ {/* Contenido de la page */} +
+``` + +--- + +## Ícono admin en AppHeader + +Agregar un ícono `Shield` (Lucide) visible solo cuando `user.platformRole === 'platform_admin'`. Posición: junto al avatar/dropdown, a la izquierda. + +```tsx +{user.platformRole === 'platform_admin' && ( + +)} +``` + +--- + +## Página Organizations (`/admin/organizations`) + +### Lista de organizaciones (clientes únicamente — `is_provider = false`) + +Query: +```typescript +const { data: orgs } = await supabase + .from('organizations') + .select(` + id, name, created_at, + processes:processes(count), + users:users(count) + `) + .eq('is_provider', false) + .order('name') +``` + +Columnas de la tabla: +| Organización | Procesos | Usuarios | Creada | Acciones | +|---|---|---|---|---| +| Banco Solar | 3 | 2 | 12 jun 2026 | Ver detalle | + +### Crear organización + +Botón "Nueva organización" → Sheet (no modal) con: +- Campo `Nombre` (requerido, trim, no vacío) +- Botón "Crear" → `supabase.from('organizations').insert({ name, is_provider: false })` +- Al crear: cerrar sheet + refetch lista + +### Detalle de organización (`/admin/organizations/$orgId`) + +Dos secciones en tabs o acordeón: + +**Procesos de esta org:** +```typescript +const { data: processes } = await supabase + .from('processes') + .select('id, name, client, updated_at') + .eq('org_id', orgId) + .order('updated_at', { ascending: false }) +``` + +Mostrar como lista simple: nombre del proceso, fecha de última actualización, link al workspace. + +Botón "Asignar proceso existente" → Sheet con dropdown de procesos sin org asignada (`org_id IS NULL`) + botón asignar: +```typescript +await supabase + .from('processes') + .update({ org_id: orgId }) + .eq('id', selectedProcessId) +``` + +**Usuarios de esta org:** +```typescript +const { data: users } = await supabase + .from('users') + .select('id, name, company, platform_role') + .eq('org_id', orgId) + .order('name') +``` + +Mostrar como lista: nombre, rol, acciones (ver Usuarios page para acciones). + +--- + +## Página Users (`/admin/users`) + +### Lista de usuarios + +```typescript +const { data: users } = await supabase + .from('users') + .select(` + id, name, company, platform_role, org_id, + organization:organizations(name, is_provider) + `) + .order('name') +``` + +Columnas: +| Nombre | Email* | Rol | Organización | Estado | Acciones | +|---|---|---|---|---|---| + +*Note: `email` está en `auth.users`, no en `public.users`. Para esta etapa, omitir el email en la lista o usar `name` únicamente. El email se puede agregar en sprint futuro via Edge Function o join con `auth.users` (solo disponible con service_role). + +**Badges de rol con color:** +- `platform_admin` → badge naranja (`#F59845`) +- `member` → badge slate +- `client_editor` → badge azul +- `client_viewer` → badge verde claro +- `guest` → badge gris, con indicador visual de "suspendido" + +### Filtro por organización + +Dropdown "Filtrar por org" encima de la tabla. Opción "Todas". Al seleccionar una org, filtra con `.eq('org_id', selectedOrgId)`. + +### Invitar usuario + +Botón "Invitar usuario" → Sheet con campos: + +``` +Nombre completo: [__________________] +Email: [__________________] +Rol: [client_editor ▾] (opciones: client_editor, client_viewer, member) +Organización: [Banco Solar ▾] (solo si rol es client_editor o client_viewer) +``` + +Al submit → llamar Edge Function: + +```typescript +const { data, error } = await supabase.functions.invoke('invite-user', { + body: { + email, + name, + platform_role: rol, + org_name: selectedOrg?.name ?? '', + } +}) + +if (error) { + // mostrar error inline +} else { + // "Invitación enviada a {email}" — toast o inline + // cerrar sheet + refetch lista +} +``` + +**Nota:** la Edge Function `invite-user` recibe `org_name` (string), no `org_id`. Busca o crea la org internamente. + +### Acciones por usuario (menú de 3 puntos o dropdown) + +``` +[ Cambiar rol ] → Sheet con dropdown de roles + botón guardar + → supabase.from('users').update({ platform_role: nuevoRol }).eq('id', userId) + +[ Suspender ] → cambia platform_role a 'guest' vía Edge Function revoke-user + Texto de confirmación: "¿Suspender a {nombre}? No podrá acceder a la plataforma." + +[ Reactivar ] → visible solo si platform_role === 'guest' + → supabase.from('users').update({ platform_role: 'client_editor' }).eq('id', userId) + IMPORTANTE: al reactivar, restaurar al rol anterior. Como no lo tenemos guardado, + restaurar a 'client_editor' como default. Documentar esto como limitación. + +[ Eliminar ] → confirmación con texto del nombre del usuario + → supabase.functions.invoke('revoke-user', { body: { userId } }) + Nota: la función actual baja el rol a 'guest'. Eliminar real queda para sprint futuro. + Mostrar al admin: "Usuario suspendido permanentemente (eliminación real en versión futura)". +``` + +**No permitir acciones sobre el usuario propio del admin** — deshabilitar las acciones si `user.id === item.id`. + +--- + +## Versión wow (requerida si costo < 40%) + +- Loading skeletons en la tabla mientras carga (usar `Skeleton` de shadcn) +- Empty state ilustrado: cuando no hay usuarios en la org, mostrar texto + icono, no tabla vacía +- Confirmación de acciones destructivas con `AlertDialog` de shadcn (no `window.confirm`) +- Badge de estado en la fila del usuario invitado recientemente: "Invitación pendiente" si el usuario no tiene `name` o `avatar_url` (indicador de que no aceptó el invite aún) +- En el sheet de invite: deshabilitar el campo "Organización" cuando el rol es `member`, mostrar "InQuality" como valor fijo + +--- + +## Archivos a crear + +``` +src/features/admin/ +├── AdminLayout.tsx ← nav tabs, guard visual +├── OrganizationsPage.tsx ← lista + crear +├── OrganizationDetailPage.tsx ← procesos + usuarios de la org +└── UsersPage.tsx ← lista + invite + acciones +``` + +Agregar en `src/router.tsx` las 4 rutas con `beforeLoad` de guard. + +--- + +## Criterios de validación + +- [ ] Navegar a `/admin` sin ser `platform_admin` redirige a `/` +- [ ] `platform_admin` ve el ícono Shield en AppHeader → navega a `/admin` +- [ ] Lista de organizaciones muestra orgs clientes (no InQuality) +- [ ] Crear organización → aparece en la lista sin recargar la página +- [ ] Detalle de org muestra procesos y usuarios de esa org +- [ ] Asignar proceso a org → proceso aparece en el detalle +- [ ] Lista de usuarios muestra todos con badge de rol correcto +- [ ] Filtro por org funciona +- [ ] Invitar usuario llama la Edge Function y muestra confirmación +- [ ] Suspender usuario: confirmación con AlertDialog, cambio a 'guest' efectivo +- [ ] No se puede ejecutar ninguna acción sobre el propio usuario admin +- [ ] Loading skeletons visibles durante fetch inicial +- [ ] `npm run test` → 558+ tests verdes +- [ ] `npm run build` limpio +- [ ] **Validación visual del director antes del OK formal** + +--- + +## NO modificar + +- `WorkspacePage`, `ReportPage`, `LibraryPage` +- Migraciones (001-015) +- Edge Functions +- Lógica de dominio, simulación, ROI +- Componentes de PDF/CSV + +--- + +## Limitaciones conocidas a documentar en comentarios + +1. `email` no disponible en `public.users` — la columna vive en `auth.users` que requiere service_role. Mostrar solo `name` por ahora. +2. "Eliminar" usuario usa `revoke-user` (baja a 'guest'). La eliminación real requiere una Edge Function `delete-user` futura. +3. "Reactivar" restaura siempre a `client_editor` — no se guarda el rol previo a la suspensión. diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index b810971..599457d 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -1,13 +1,17 @@ import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react' import { SupabaseAuthService } from './SupabaseAuthService' +import { supabase } from '@/lib/supabase' import type { AuthUser } from './types' interface AuthContextValue { user: AuthUser | null loading: boolean signIn: () => Promise + signInWithEmailPassword: (email: string, password: string) => Promise<{ error: string | null }> signOut: () => Promise updateUser: (updates: Partial>) => void + pendingPasswordReset: boolean + clearPasswordReset: () => void } const AuthContext = createContext(null) @@ -60,6 +64,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { ) // Si es un OAuth redirect, mantener loading=true hasta que Supabase procese el token. const [loading, setLoading] = useState(isOAuthRedirect) + const [pendingPasswordReset, setPendingPasswordReset] = useState(false) useEffect(() => { // Verificar y sincronizar con Supabase en background. @@ -72,6 +77,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => unsubscribe() }, []) + // Suscripción directa para PASSWORD_RECOVERY (no pasa por authService para evitar + // mezclar el flujo normal con el de reset de contraseña). + useEffect(() => { + const { data: { subscription } } = supabase.auth.onAuthStateChange((event) => { + if (event === 'PASSWORD_RECOVERY') { + setPendingPasswordReset(true) + } + }) + return () => subscription.unsubscribe() + }, []) + // Sincronización con tabla users FUERA del lock de auth. // Se ejecuta al cambiar de usuario (login/logout), NO en cada updateUser(). // user?.id es primitivo estable: no cambia cuando se edita nombre o empresa. @@ -90,12 +106,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Upsert idempotente: crea la fila si no existe (primer login), // ignoreDuplicates evita sobreescribir name/company editados posteriormente. await authService.upsertUserOnLogin(capturedUser, controller.signal) - // Leer company y name editado desde la tabla (pueden diferir del user_metadata de Google). + // Leer platform_role, org_id, name y company desde la tabla (sobrescriben la heurística de email). const profile = await authService.syncProfileFromDB(userId, controller.signal) if (profile) { setUser((prev) => prev?.id === userId - ? { ...prev, company: profile.company, name: profile.name || prev.name } + ? { + ...prev, + name: profile.name || prev.name, + company: profile.company, + platformRole: profile.platformRole, + orgId: profile.orgId, + } : prev ) } @@ -115,17 +137,34 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signIn = useCallback(() => authService.signInWithGoogle(), []) + const signInWithEmailPassword = useCallback( + (email: string, password: string) => authService.signInWithEmailPassword(email, password), + [] + ) + const signOut = useCallback(async () => { await authService.signOut() setUser(null) + setPendingPasswordReset(false) }, []) const updateUser = useCallback((updates: Partial>) => { setUser((prev) => prev ? { ...prev, ...updates } : prev) }, []) + const clearPasswordReset = useCallback(() => setPendingPasswordReset(false), []) + return ( - + {children} ) diff --git a/src/auth/SupabaseAuthService.ts b/src/auth/SupabaseAuthService.ts index 9ab9398..7d625e6 100644 --- a/src/auth/SupabaseAuthService.ts +++ b/src/auth/SupabaseAuthService.ts @@ -15,6 +15,11 @@ export class SupabaseAuthService implements AuthService { if (error) throw error } + async signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> { + const { error } = await supabase.auth.signInWithPassword({ email, password }) + return { error: error?.message ?? null } + } + async signOut(): Promise { const { error } = await supabase.auth.signOut() if (error) throw error @@ -34,6 +39,8 @@ export class SupabaseAuthService implements AuthService { const { data: { subscription } } = supabase.auth.onAuthStateChange( (event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback try { + // Token refresh silencioso — no disparar re-sync ni setLoading + if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') return if (!session?.user) { // INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión // (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout. @@ -41,10 +48,12 @@ export class SupabaseAuthService implements AuthService { if (event !== 'INITIAL_SESSION') callback(null) return } - if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN') return + // PASSWORD_RECOVERY tiene sesión activa — incluir para que AuthContext establezca el usuario + // antes de redirigir a /reset-password. La señal de redirect se maneja en AuthContext. + if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN' && event !== 'PASSWORD_RECOVERY') return // Llamar callback inmediatamente con datos básicos del token OAuth. // NO hacer queries a Supabase desde aquí — el lock de auth está activo (GoTrueClient.js L3213). - // El upsert y la lectura de company/name ocurren en AuthContext fuera del lock. + // El upsert y la lectura de platform_role/org_id ocurren en AuthContext fuera del lock. callback(this.buildAuthUser(session.user)) } catch (err) { console.error('Error en onAuthStateChange:', err) @@ -57,16 +66,19 @@ export class SupabaseAuthService implements AuthService { return () => subscription.unsubscribe() } - // Leer company y name editados desde la tabla users (pueden diferir del user_metadata de Google). + // Leer platform_role, org_id, company y name desde la tabla users. + // Sobrescribe la heurística de email de buildAuthUser con el valor real de DB. // Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal. // signal permite cancelar si el servidor tarda demasiado (libera el processLock). - async syncProfileFromDB(userId: string, signal?: AbortSignal): Promise | null> { - const base = supabase.from('users').select('company, name').eq('id', userId) + async syncProfileFromDB(userId: string, signal?: AbortSignal): Promise | null> { + const base = supabase.from('users').select('name, company, platform_role, org_id').eq('id', userId) const { data } = await (signal ? base.abortSignal(signal) : base).maybeSingle() if (!data) return null return { name: ((data.name as string | null | undefined) || undefined) as unknown as string, company: (data.company as string | null) ?? undefined, + platformRole: (data.platform_role as AuthUser['platformRole']) ?? 'guest', + orgId: (data.org_id as string | null) ?? undefined, } } @@ -88,7 +100,7 @@ export class SupabaseAuthService implements AuthService { } // Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB. - // El rol se deriva del dominio del email (misma lógica que el upsert en SIGNED_IN). + // El rol se deriva del dominio del email (valor optimista, syncProfileFromDB lo corrige después). private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record }): AuthUser { const email = supabaseUser.email ?? '' return { diff --git a/src/auth/types.ts b/src/auth/types.ts index e4c566d..26feae5 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -3,12 +3,14 @@ export interface AuthUser { email: string name: string avatarUrl?: string - platformRole: 'platform_admin' | 'member' | 'guest' + platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest' company?: string + orgId?: string } export interface AuthService { signInWithGoogle(): Promise + signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> signOut(): Promise getCurrentUser(): Promise onAuthStateChange(callback: (user: AuthUser | null) => void): () => void diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index f007b7c..78f24d9 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Link } from '@tanstack/react-router' -import { LogOut, UserPen } from 'lucide-react' +import { LogOut, UserPen, Shield } from 'lucide-react' import { useAuth } from '@/auth/AuthContext' import { DropdownMenu, @@ -77,6 +77,20 @@ export function AppHeader() { > InQ ROI + {user?.platformRole === 'platform_admin' && ( + + + + )} {user && ( diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..5ab0656 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/src/features/admin/AdminLayout.tsx b/src/features/admin/AdminLayout.tsx new file mode 100644 index 0000000..f434336 --- /dev/null +++ b/src/features/admin/AdminLayout.tsx @@ -0,0 +1,70 @@ +import { useEffect } from 'react' +import { Outlet, Link, useNavigate, useRouterState } from '@tanstack/react-router' +import { Loader2 } from 'lucide-react' +import { useAuth } from '@/auth/AuthContext' +import { AppHeader } from '@/components/AppHeader' +import { cn } from '@/lib/utils' + +export function AdminLayout() { + const { user, loading } = useAuth() + const navigate = useNavigate() + const pathname = useRouterState({ select: (s) => s.location.pathname }) + + useEffect(() => { + if (loading) return + if (!user || user.platformRole !== 'platform_admin') { + void navigate({ to: '/' }) + } + }, [user, loading, navigate]) + + if (loading) { + return ( +
+ +
+ ) + } + + if (!user || user.platformRole !== 'platform_admin') return null + + return ( +
+ +
+ + +
+
+ ) +} + +function AdminNavTab({ + to, + active, + children, +}: { + to: string + active: boolean + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/src/features/admin/OrganizationDetailPage.tsx b/src/features/admin/OrganizationDetailPage.tsx new file mode 100644 index 0000000..9c4fa01 --- /dev/null +++ b/src/features/admin/OrganizationDetailPage.tsx @@ -0,0 +1,291 @@ +import { useEffect, useState } from 'react' +import { useParams, useNavigate, Link } from '@tanstack/react-router' +import { ArrowLeft, FileText, Users, FolderX, UserX, GitBranch } from 'lucide-react' +import { supabase } from '@/lib/supabase' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetFooter, +} from '@/components/ui/sheet' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { RoleBadge } from './RoleBadge' + +interface OrgProcess { + id: string + name: string + client: string | null + updated_at: string +} + +interface OrgUser { + id: string + name: string | null + company: string | null + platform_role: string + avatar_url?: string | null +} + +interface UnassignedProcess { + id: string + name: string +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('es-PY', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) +} + +export function OrganizationDetailPage() { + const { orgId } = useParams({ from: '/admin/organizations/$orgId' }) + const navigate = useNavigate() + + const [orgName, setOrgName] = useState('') + const [processes, setProcesses] = useState([]) + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + + // Asignar proceso + const [assignOpen, setAssignOpen] = useState(false) + const [unassigned, setUnassigned] = useState([]) + const [loadingUnassigned, setLoadingUnassigned] = useState(false) + const [selectedProcessId, setSelectedProcessId] = useState('') + const [assigning, setAssigning] = useState(false) + const [assignError, setAssignError] = useState(null) + + const fetchDetail = async () => { + const [orgRes, procRes, userRes] = await Promise.all([ + supabase.from('organizations').select('name').eq('id', orgId).single(), + supabase.from('processes').select('id, name, client, updated_at').eq('org_id', orgId).order('updated_at', { ascending: false }), + supabase.from('users').select('id, name, company, platform_role, avatar_url').eq('org_id', orgId).order('name'), + ]) + if (orgRes.data) setOrgName((orgRes.data as { name: string }).name) + setProcesses((procRes.data as OrgProcess[] | null) ?? []) + setUsers((userRes.data as OrgUser[] | null) ?? []) + setLoading(false) + } + + useEffect(() => { void fetchDetail() }, [orgId]) + + const openAssign = async () => { + setSelectedProcessId('') + setAssignError(null) + setAssignOpen(true) + setLoadingUnassigned(true) + const { data } = await supabase + .from('processes') + .select('id, name') + .is('org_id', null) + .order('name') + setUnassigned((data as UnassignedProcess[] | null) ?? []) + setLoadingUnassigned(false) + } + + const handleAssign = async () => { + if (!selectedProcessId) { + setAssignError('Seleccioná un proceso') + return + } + setAssigning(true) + const { error } = await supabase + .from('processes') + .update({ org_id: orgId }) + .eq('id', selectedProcessId) + if (error) { + setAssignError(error.message) + setAssigning(false) + } else { + setAssignOpen(false) + setAssigning(false) + void fetchDetail() + } + } + + if (loading) { + return ( +
+ + +
+ ) + } + + return ( + <> +
+ +

{orgName}

+
+ + + + + + Procesos ({processes.length}) + + + + Usuarios ({users.length}) + + + + {/* ─── Tab: Procesos ─────────────────────────────────────────────── */} + +
+

+ {processes.length === 0 + ? 'No hay procesos asignados a esta organización.' + : `${processes.length} proceso${processes.length !== 1 ? 's' : ''} asignado${processes.length !== 1 ? 's' : ''}.`} +

+ +
+ + {processes.length === 0 ? ( + } + message="Esta organización no tiene procesos asignados. Podés asignar uno existente o importar uno nuevo." + /> + ) : ( +
+ + + + + + + + + {processes.map((p) => ( + + + + + + ))} + +
ProcesoÚltima actualización +
{p.name}{formatDate(p.updated_at)} + + Abrir workspace → + +
+
+ )} +
+ + {/* ─── Tab: Usuarios ─────────────────────────────────────────────── */} + + {users.length === 0 ? ( + } + message="No hay usuarios en esta organización. Invitá usuarios desde la sección Usuarios." + /> + ) : ( +
+ + + + + + + + + + {users.map((u) => ( + + + + + + ))} + +
NombreRolEmpresa
+ {u.name || '—'} + {!u.name && !u.avatar_url && ( + + Invitación pendiente + + )} + + + {u.company ?? '—'}
+
+ )} +
+
+ + {/* ─── Sheet: Asignar proceso ─────────────────────────────────────── */} + + + + Asignar proceso a {orgName} + +
+ {loadingUnassigned ? ( + + ) : unassigned.length === 0 ? ( +

No hay procesos sin organización asignada.

+ ) : ( + + )} + {assignError &&

{assignError}

} + + + +
+
+
+ + ) +} + +function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) { + return ( +
+ {icon} +

{message}

+
+ ) +} diff --git a/src/features/admin/OrganizationsPage.tsx b/src/features/admin/OrganizationsPage.tsx new file mode 100644 index 0000000..a40b1fe --- /dev/null +++ b/src/features/admin/OrganizationsPage.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from 'react' +import { Link } from '@tanstack/react-router' +import { Building2, Plus } from 'lucide-react' +import { supabase } from '@/lib/supabase' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetFooter, +} from '@/components/ui/sheet' + +interface OrgRow { + id: string + name: string + created_at: string + processes: [{ count: number }] + users: [{ count: number }] +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('es-PY', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) +} + +export function OrganizationsPage() { + const [orgs, setOrgs] = useState([]) + const [loading, setLoading] = useState(true) + const [createOpen, setCreateOpen] = useState(false) + const [orgName, setOrgName] = useState('') + const [createError, setCreateError] = useState(null) + const [creating, setCreating] = useState(false) + + const fetchOrgs = async () => { + const { data } = await supabase + .from('organizations') + .select('id, name, created_at, processes:processes(count), users:users(count)') + .eq('is_provider', false) + .order('name') + setOrgs((data as OrgRow[] | null) ?? []) + setLoading(false) + } + + useEffect(() => { void fetchOrgs() }, []) + + const openCreate = () => { + setOrgName('') + setCreateError(null) + setCreateOpen(true) + } + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault() + const trimmed = orgName.trim() + if (!trimmed) { + setCreateError('El nombre es requerido') + return + } + setCreating(true) + const { error } = await supabase + .from('organizations') + .insert({ name: trimmed, is_provider: false }) + if (error) { + setCreateError( + error.message.toLowerCase().includes('duplicate') || error.message.toLowerCase().includes('unique') + ? 'Ya existe una organización con ese nombre' + : error.message + ) + setCreating(false) + } else { + setCreateOpen(false) + setCreating(false) + void fetchOrgs() + } + } + + return ( + <> +
+

Organizaciones clientes

+ +
+ + {loading ? ( + + ) : orgs.length === 0 ? ( + } + message="No hay organizaciones clientes aún. Creá la primera con el botón de arriba." + /> + ) : ( +
+ + + + + + + + + + + {orgs.map((org) => ( + + + + + + + + ))} + +
OrganizaciónProcesosUsuariosCreada +
{org.name} + {org.processes[0]?.count ?? 0} + + {org.users[0]?.count ?? 0} + {formatDate(org.created_at)} + + Ver detalle → + +
+
+ )} + + + + + Nueva organización + +
+
+ + setOrgName(e.target.value)} + placeholder="ej. Banco Solar SA" + disabled={creating} + autoFocus + /> + {createError &&

{createError}

} +
+ + + +
+
+
+ + ) +} + +function TableSkeleton({ rows }: { rows: number }) { + return ( +
+
+
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + + + +
+ ))} +
+
+ ) +} + +function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) { + return ( +
+ {icon} +

{message}

+
+ ) +} diff --git a/src/features/admin/RoleBadge.tsx b/src/features/admin/RoleBadge.tsx new file mode 100644 index 0000000..cbb8318 --- /dev/null +++ b/src/features/admin/RoleBadge.tsx @@ -0,0 +1,38 @@ +// Badges de rol con colores por spec: platform_admin naranja, member slate, +// client_editor azul, client_viewer verde, guest gris/suspendido +const ROLE_STYLES: Record = { + platform_admin: { + label: 'Platform Admin', + className: 'bg-orange-100 text-orange-700 border-orange-200', + }, + member: { + label: 'Member', + className: 'bg-slate-100 text-slate-700 border-slate-200', + }, + client_editor: { + label: 'Client Editor', + className: 'bg-blue-100 text-blue-700 border-blue-200', + }, + client_viewer: { + label: 'Client Viewer', + className: 'bg-emerald-100 text-emerald-700 border-emerald-200', + }, + guest: { + label: 'Suspendido', + className: 'bg-gray-100 text-gray-500 border-gray-200', + }, +} + +export function RoleBadge({ role }: { role: string }) { + const style = ROLE_STYLES[role] ?? { + label: role, + className: 'bg-gray-100 text-gray-500 border-gray-200', + } + return ( + + {style.label} + + ) +} diff --git a/src/features/admin/UsersPage.tsx b/src/features/admin/UsersPage.tsx new file mode 100644 index 0000000..1485447 --- /dev/null +++ b/src/features/admin/UsersPage.tsx @@ -0,0 +1,600 @@ +/** + * Limitaciones conocidas documentadas: + * 1. email no está en public.users (vive en auth.users, requiere service_role) → solo se muestra name. + * 2. "Eliminar" llama revoke-user que baja platform_role a 'guest'. Eliminación real requiere Edge Function futura. + * 3. "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión. + */ +import { useEffect, useState } from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { UserX, UserPlus, MoreVertical } from 'lucide-react' +import { supabase } from '@/lib/supabase' +import { useAuth } from '@/auth/AuthContext' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetFooter, +} from '@/components/ui/sheet' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { RoleBadge } from './RoleBadge' + +interface AdminUser { + id: string + name: string | null + company: string | null + platform_role: string + org_id: string | null + avatar_url: string | null + organization: { name: string; is_provider: boolean } | null +} + +interface OrgOption { + id: string + name: string +} + +// ─── ConfirmDialog usando @radix-ui/react-dialog (sin @radix-ui/react-alert-dialog) ── + +interface ConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + confirmLabel?: string + onConfirm: () => void + loading?: boolean + destructive?: boolean +} + +function ConfirmDialog({ + open, onOpenChange, title, description, + confirmLabel = 'Confirmar', + onConfirm, loading = false, destructive = false, +}: ConfirmDialogProps) { + return ( + + + + e.preventDefault()} + > + + {title} + + + {description} + +
+ + +
+
+
+
+ ) +} + +// ─── Página principal ───────────────────────────────────────────────────────── + +const INVITE_ROLES = ['client_editor', 'client_viewer', 'member'] as const +type InviteRole = typeof INVITE_ROLES[number] + +const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'] as const + +export function UsersPage() { + const { user: adminUser } = useAuth() + + const [users, setUsers] = useState([]) + const [orgs, setOrgs] = useState([]) + const [loadingUsers, setLoadingUsers] = useState(true) + const [filterOrgId, setFilterOrgId] = useState('all') + + // Invite sheet + const [inviteOpen, setInviteOpen] = useState(false) + const [inviteName, setInviteName] = useState('') + const [inviteEmail, setInviteEmail] = useState('') + const [inviteRole, setInviteRole] = useState('client_editor') + const [inviteOrgName, setInviteOrgName] = useState('') + const [inviteError, setInviteError] = useState(null) + const [inviteSuccess, setInviteSuccess] = useState(null) + const [inviting, setInviting] = useState(false) + + // Change role sheet + const [changeRoleUser, setChangeRoleUser] = useState(null) + const [changeRoleOpen, setChangeRoleOpen] = useState(false) + const [newRole, setNewRole] = useState('') + const [changingRole, setChangingRole] = useState(false) + const [changeRoleError, setChangeRoleError] = useState(null) + + // Confirmations + const [suspendTarget, setSuspendTarget] = useState(null) + const [suspending, setSuspending] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + const [reactivateTarget, setReactivateTarget] = useState(null) + const [reactivating, setReactivating] = useState(false) + + const fetchUsers = async () => { + const query = supabase + .from('users') + .select('id, name, company, platform_role, org_id, avatar_url, organization:organizations(name, is_provider)') + .order('name') + const finalQuery = filterOrgId !== 'all' + ? query.eq('org_id', filterOrgId) + : query + const { data } = await finalQuery + setUsers((data as AdminUser[] | null) ?? []) + setLoadingUsers(false) + } + + const fetchOrgs = async () => { + const { data } = await supabase + .from('organizations') + .select('id, name') + .order('name') + setOrgs((data as OrgOption[] | null) ?? []) + } + + useEffect(() => { + void fetchOrgs() + }, []) + + useEffect(() => { + setLoadingUsers(true) + void fetchUsers() + }, [filterOrgId]) // eslint-disable-line react-hooks/exhaustive-deps + + // ── Invite user ────────────────────────────────────────────────────────── + + const openInvite = () => { + setInviteName('') + setInviteEmail('') + setInviteRole('client_editor') + setInviteOrgName('') + setInviteError(null) + setInviteSuccess(null) + setInviteOpen(true) + } + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault() + setInviteError(null) + setInviteSuccess(null) + if (!inviteName.trim()) { setInviteError('El nombre es requerido'); return } + if (!inviteEmail.trim() || !inviteEmail.includes('@')) { setInviteError('Email inválido'); return } + if ((inviteRole === 'client_editor' || inviteRole === 'client_viewer') && !inviteOrgName.trim()) { + setInviteError('La organización es requerida para este rol') + return + } + setInviting(true) + const { error } = await supabase.functions.invoke('invite-user', { + body: { + email: inviteEmail.trim(), + name: inviteName.trim(), + platform_role: inviteRole, + org_name: inviteRole === 'member' ? 'InQuality' : inviteOrgName.trim(), + }, + }) + if (error) { + setInviteError(typeof error === 'object' && 'message' in error ? String(error.message) : String(error)) + setInviting(false) + } else { + setInviteSuccess(`Invitación enviada a ${inviteEmail.trim()}`) + setInviting(false) + void fetchUsers() + } + } + + // ── Change role ────────────────────────────────────────────────────────── + + const openChangeRole = (u: AdminUser) => { + setChangeRoleUser(u) + setNewRole(u.platform_role) + setChangeRoleError(null) + setChangeRoleOpen(true) + } + + const handleChangeRole = async () => { + if (!changeRoleUser || !newRole) return + setChangingRole(true) + const { error } = await supabase + .from('users') + .update({ platform_role: newRole }) + .eq('id', changeRoleUser.id) + if (error) { + setChangeRoleError(error.message) + setChangingRole(false) + } else { + setChangeRoleOpen(false) + setChangingRole(false) + void fetchUsers() + } + } + + // ── Suspend (revoke-user vía Edge Function) ─────────────────────────────── + + const handleSuspend = async () => { + if (!suspendTarget) return + setSuspending(true) + const { error } = await supabase.functions.invoke('revoke-user', { + body: { userId: suspendTarget.id }, + }) + if (error) { + console.error('Error al suspender usuario:', error) + } + setSuspendTarget(null) + setSuspending(false) + void fetchUsers() + } + + // ── Delete (también usa revoke-user — baja a guest, eliminación real futura) ─ + + const handleDelete = async () => { + if (!deleteTarget) return + setDeleting(true) + const { error } = await supabase.functions.invoke('revoke-user', { + body: { userId: deleteTarget.id }, + }) + if (error) { + console.error('Error al eliminar usuario:', error) + } + setDeleteTarget(null) + setDeleting(false) + void fetchUsers() + } + + // ── Reactivate (restore to client_editor — rol previo no se guarda) ─────── + + const handleReactivate = async () => { + if (!reactivateTarget) return + setReactivating(true) + await supabase + .from('users') + .update({ platform_role: 'client_editor' }) + .eq('id', reactivateTarget.id) + setReactivateTarget(null) + setReactivating(false) + void fetchUsers() + } + + // ───────────────────────────────────────────────────────────────────────── + + const displayedUsers = users + + return ( + <> +
+
+

Usuarios

+ +
+ +
+ + {loadingUsers ? ( + + ) : displayedUsers.length === 0 ? ( + } + message={ + filterOrgId !== 'all' + ? 'No hay usuarios en esta organización.' + : 'No hay usuarios registrados aún.' + } + /> + ) : ( +
+ + + + + + + + + + {displayedUsers.map((u) => { + const isSelf = adminUser?.id === u.id + const isPending = !u.name && !u.avatar_url + + return ( + + + + + + + ) + })} + +
NombreRolOrganización +
+
+ + {u.name || Sin nombre} + + {isPending && ( + + Invitación pendiente + + )} + {isSelf && ( + + Tú + + )} +
+
+ + + {u.organization?.name ?? '—'} + + + + + + + openChangeRole(u)}> + Cambiar rol + + + {u.platform_role === 'guest' ? ( + setReactivateTarget(u)}> + Reactivar usuario + + ) : ( + setSuspendTarget(u)} + > + Suspender + + )} + setDeleteTarget(u)} + > + Eliminar + + + +
+
+ )} + + {/* ─── Sheet: Invitar usuario ──────────────────────────────────────── */} + + + + Invitar usuario + +
+ + setInviteName(e.target.value)} + placeholder="Ej. Ana Martínez" + disabled={inviting} + autoFocus + /> + + + + setInviteEmail(e.target.value)} + placeholder="ana@empresa.com" + disabled={inviting} + /> + + + + + + + {inviteRole === 'member' ? ( + + + + ) : ( + + setInviteOrgName(e.target.value)} + placeholder="Nombre de la organización" + disabled={inviting} + /> + + {orgs.map((o) => ( + + + )} + + {inviteError &&

{inviteError}

} + {inviteSuccess &&

{inviteSuccess}

} + + + + +
+
+
+ + {/* ─── Sheet: Cambiar rol ──────────────────────────────────────────── */} + + + + Cambiar rol — {changeRoleUser?.name} + +
+ + + + {changeRoleError &&

{changeRoleError}

} + + + +
+
+
+ + {/* ─── ConfirmDialog: Suspender ────────────────────────────────────── */} + !open && setSuspendTarget(null)} + title={`¿Suspender a ${suspendTarget?.name ?? 'este usuario'}?`} + description="No podrá acceder a la plataforma mientras esté suspendido. Podés reactivarlo después." + confirmLabel="Suspender" + onConfirm={handleSuspend} + loading={suspending} + destructive + /> + + {/* ─── ConfirmDialog: Eliminar ─────────────────────────────────────── */} + !open && setDeleteTarget(null)} + title={`¿Eliminar a ${deleteTarget?.name ?? 'este usuario'}?`} + description="El usuario quedará suspendido permanentemente. (Nota: la eliminación real de la cuenta estará disponible en una versión futura.)" + confirmLabel="Eliminar" + onConfirm={handleDelete} + loading={deleting} + destructive + /> + + {/* ─── ConfirmDialog: Reactivar ────────────────────────────────────── */} + !open && setReactivateTarget(null)} + title={`¿Reactivar a ${reactivateTarget?.name ?? 'este usuario'}?`} + description="Se le asignará el rol Client Editor. (Nota: el rol original no se conservó al momento de la suspensión.)" + confirmLabel="Reactivar" + onConfirm={handleReactivate} + loading={reactivating} + /> + + ) +} + +function Field({ label, id, children }: { label: string; id: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +function TableSkeleton({ rows }: { rows: number }) { + return ( +
+
+
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + + +
+ ))} +
+
+ ) +} + +function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) { + return ( +
+ {icon} +

{message}

+
+ ) +} diff --git a/src/features/auth/ResetPasswordPage.tsx b/src/features/auth/ResetPasswordPage.tsx new file mode 100644 index 0000000..27e5380 --- /dev/null +++ b/src/features/auth/ResetPasswordPage.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { supabase } from '@/lib/supabase' +import { useAuth } from '@/auth/AuthContext' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' + +export function ResetPasswordPage() { + const { clearPasswordReset } = useAuth() + const navigate = useNavigate() + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + if (password.length < 8) { + setError('La contraseña debe tener al menos 8 caracteres') + return + } + if (password !== confirm) { + setError('Las contraseñas no coinciden') + return + } + + setLoading(true) + const { error: updateError } = await supabase.auth.updateUser({ password }) + if (updateError) { + setError(updateError.message) + setLoading(false) + } else { + clearPasswordReset() + void navigate({ to: '/' }) + } + } + + return ( +
+
+ + + InQuality + +
+

Establecer contraseña

+

Creá una contraseña para tu cuenta InQ ROI

+
+ +
+
+ + setPassword(e.target.value)} + placeholder="Mínimo 8 caracteres" + disabled={loading} + /> +
+ +
+ + setConfirm(e.target.value)} + placeholder="Repetí la contraseña" + disabled={loading} + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+
+ ) +} diff --git a/src/features/login/LoginPage.tsx b/src/features/login/LoginPage.tsx index 30d5921..1ff9ecf 100644 --- a/src/features/login/LoginPage.tsx +++ b/src/features/login/LoginPage.tsx @@ -1,4 +1,14 @@ +import { useState } from 'react' +import { z } from 'zod' import { useAuth } from '@/auth/AuthContext' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' + +const loginSchema = z.object({ + email: z.string().email('Email inválido'), + password: z.string().min(1, 'La contraseña es requerida'), +}) function GoogleIcon() { return ( @@ -12,12 +22,39 @@ function GoogleIcon() { } export function LoginPage() { - const { signIn, loading } = useAuth() + const { signIn, signInWithEmailPassword, loading } = useAuth() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [formError, setFormError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleEmailLogin = async (e: React.FormEvent) => { + e.preventDefault() + setFormError(null) + + const result = loginSchema.safeParse({ email, password }) + if (!result.success) { + setFormError(result.error.issues[0].message) + return + } + + setIsSubmitting(true) + const { error } = await signInWithEmailPassword(email, password) + if (error) { + setFormError( + error === 'Invalid login credentials' + ? 'Email o contraseña incorrectos' + : error + ) + setIsSubmitting(false) + } + // En login exitoso no resetear isSubmitting — el router redirige y desmonta el componente + } return (