From 62fe15517e5155bff8124d13d97c5e14d6725dae Mon Sep 17 00:00:00 2001 From: Mateo Pavoni Date: Mon, 8 Jun 2026 07:32:53 -0300 Subject: [PATCH 01/22] chore(deploy): separa entornos dev/prod (environments.md + .env.example) --- .env.example | 16 +++++++++++--- docs/environments.md | 52 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 docs/environments.md diff --git a/.env.example b/.env.example index 556d63b..c13fea8 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,22 @@ -# Supabase +# =================================================================== +# Vimet — Variables de entorno +# =================================================================== +# Copiar a .env.local y completar. NUNCA committear .env.local. +# Dos entornos (ver docs/environments.md): +# dev → Supabase vimet-dev (Vercel Preview) +# prod → Supabase vimet-prod (Vercel Production) +# En local apuntá SIEMPRE a vimet-dev, nunca a prod. +# =================================================================== + +# Supabase — DISTINTO por entorno (dev=vimet-dev, prod=vimet-prod) NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= -# Resend (formulario contacto público) +# Resend (formulario contacto público) — compartido entre entornos RESEND_API_KEY= RESEND_FROM_EMAIL=onboarding@resend.dev COMPANY_EMAIL=hola@vimet.com -# App +# App — DISTINTO por entorno: apuntar al dominio del entorno (dev usa la URL de la Preview) NEXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/docs/environments.md b/docs/environments.md new file mode 100644 index 0000000..1696b0a --- /dev/null +++ b/docs/environments.md @@ -0,0 +1,52 @@ +# Entornos — Vimet + +App con **Supabase** (auth + roles + área de pacientes/staff). Trabaja con **dos entornos** +separados para probar el flujo de invitaciones, activación y roles sin tocar datos reales. + +| Rama | Vercel Environment | URL | Supabase project | +|-------|--------------------|--------------------------------------|------------------| +| `main`| **Production** | https://vimet.com (o dominio prod) | `vimet-prod` | +| `dev` | **Preview** | `vimet-git-dev-.vercel.app` | `vimet-dev` | + +**Flujo:** desarrollás en `dev` → Vercel autodeploya a Preview (Supabase `vimet-dev`) → +probás invitaciones/altas/roles con usuarios de prueba → merge `dev` → `main` → Production. + +> Toda la infra (Vercel/Supabase) la configurás vos manualmente. +> El código, las migraciones y esta guía ya están listos. + +--- + +## Setup inicial (una sola vez) + +### 1. Segundo proyecto Supabase (DEV) +1. New Project `vimet-dev`, región **South America (São Paulo)**. +2. **SQL Editor** → correr en orden [`supabase/migrations/`](../supabase/migrations/) + (`0001_init.sql` → `0002_seed.sql` → … → `0006_invited_role.sql`). +3. **Settings → API** → copiar URL + anon + service_role del proyecto DEV. +4. **Auth**: configurar el SMTP (Resend) y las **redirect/site URLs** apuntando a la **Preview** + (clave para que los links de invitación de dev no apunten a producción). + +### 2. Variables en Vercel, por entorno +Vercel → **Settings → Environment Variables**, eligiendo el **Environment** en cada una: + +| Variable | ¿Difiere? | Production (`main`) | Preview (`dev`) | +|---------------------------------|-----------|-------------------------|--------------------------| +| `NEXT_PUBLIC_SUPABASE_URL` | **sí** | `vimet-prod` | `vimet-dev` | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | **sí** | anon prod | anon dev | +| `SUPABASE_SERVICE_ROLE_KEY` | **sí** | service_role prod | service_role dev | +| `NEXT_PUBLIC_SITE_URL` | **sí** | dominio prod | URL de la Preview | +| `RESEND_API_KEY` | no | misma | misma | +| `RESEND_FROM_EMAIL` | no | mismo | mismo (o `[DEV]`) | +| `COMPANY_EMAIL` | recom. | `hola@vimet.com` | tu mail de prueba | + +> `NEXT_PUBLIC_SITE_URL` y las redirect URLs de Supabase Auth deben coincidir con el dominio del +> entorno; si no, los links de invitación/recuperación se rompen. + +### 3. Confirmar la rama de producción +Vercel → **Settings → Git → Production Branch = `main`**. Cualquier otra rama (incl. `dev`) → Preview. + +--- + +## Desarrollo local +`.env.local` apunta **siempre a `vimet-dev`**, nunca a prod. Partir de [`.env.example`](../.env.example). +NUNCA committear `.env.local`. From 3762b615f0fdf35aee4c1a2f3537d4060b437fab Mon Sep 17 00:00:00 2001 From: Mateo Pavoni Date: Tue, 2 Jun 2026 14:54:08 -0300 Subject: [PATCH 02/22] =?UTF-8?q?fix(paciente):=20ocultar=20navbar=20p?= =?UTF-8?q?=C3=BAblica=20en=20=C3=A1rea=20paciente=20+=20header=20propio?= =?UTF-8?q?=20con=20Salir=20+=20subnav=20que=20envuelve=20sin=20scroll=20h?= =?UTF-8?q?orizontal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(paciente)/layout.tsx | 51 ++++++++++++++++++++++++++-------- components/navbar.tsx | 14 +++++++++- components/paciente-subnav.tsx | 4 +-- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/app/(paciente)/layout.tsx b/app/(paciente)/layout.tsx index 4a33b68..97f35c0 100644 --- a/app/(paciente)/layout.tsx +++ b/app/(paciente)/layout.tsx @@ -1,8 +1,10 @@ +import { LogOut } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' import { redirect } from 'next/navigation' -import { Suspense, type ReactNode } from 'react' +import { type ReactNode } from 'react' -import { Footer } from '@/components/footer' -import { Navbar } from '@/components/navbar' +import { logoutAction } from '@/actions/auth' import { PacienteSubnav } from '@/components/paciente-subnav' import { createClient } from '@/lib/supabase/server' @@ -27,7 +29,7 @@ export default async function PacienteLayout({ children }: { children: ReactNode const { data: profile } = await supabase .from('profiles') - .select('rol') + .select('rol, nombre') .eq('id', user.id) .maybeSingle() @@ -36,17 +38,44 @@ export default async function PacienteLayout({ children }: { children: ReactNode } return ( - <> - - - -
+
+
+
+ + VIMET + +
+ {profile?.nombre ? ( + + Hola, {profile.nombre} + + ) : null} +
+ +
+
+
+
+ +
{children}
-
- +
) } diff --git a/components/navbar.tsx b/components/navbar.tsx index 80d3973..25349f6 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -18,7 +18,19 @@ const links = [ { href: '/contacto', label: 'Contacto' }, ] -const HIDE_PREFIXES = ['/admin', '/login', '/registro', '/turnos/nuevo'] +const HIDE_PREFIXES = [ + '/admin', + '/login', + '/registro', + '/turnos/nuevo', + '/mis-turnos', + '/mi-ficha', + '/mi-progreso', + '/mis-planes', + '/feedback-semanal', + '/mis-objetivos', + '/mis-recursos', +] export function Navbar() { const pathname = usePathname() diff --git a/components/paciente-subnav.tsx b/components/paciente-subnav.tsx index 951c395..02709f7 100644 --- a/components/paciente-subnav.tsx +++ b/components/paciente-subnav.tsx @@ -33,8 +33,8 @@ export function PacienteSubnav({ }) { const pathname = usePathname() return ( -
diff --git a/components/admin-sidebar.tsx b/components/admin-sidebar.tsx index db2fdfb..cb53f9e 100644 --- a/components/admin-sidebar.tsx +++ b/components/admin-sidebar.tsx @@ -4,7 +4,7 @@ import { CalendarDays, LayoutDashboard, LogOut, Settings, UsersRound } from 'luc import Link from 'next/link' import { usePathname } from 'next/navigation' -import { logoutAction } from '@/actions/auth' +import { LogoutButton } from '@/components/logout-button' import { cn } from '@/lib/utils' const links = [ @@ -47,14 +47,9 @@ export function AdminSidebar({ name }: { name: string }) {
-
- -
+ + Cerrar sesión +
{/* Mobile bar */} @@ -79,15 +74,12 @@ export function AdminSidebar({ name }: { name: string }) { ) })} -
- -
+ + + diff --git a/components/logout-button.tsx b/components/logout-button.tsx new file mode 100644 index 0000000..d565b98 --- /dev/null +++ b/components/logout-button.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { type ButtonHTMLAttributes, type ReactNode, useState } from 'react' + +import { createClient } from '@/lib/supabase/client' + +/** + * Logout del lado del cliente: el signOut corre sobre el cliente browser de + * Supabase para que dispare `onAuthStateChange` (así la navbar y demás UI que + * escuchan la sesión se actualizan al instante) y además limpie las cookies. + * Un server action solo limpiaba las cookies en el server y dejaba el estado + * en memoria del cliente colgado mostrando "Panel"/"Mi espacio". + */ +export function LogoutButton({ + className, + children, + ...props +}: { + className?: string + children: ReactNode +} & Omit, 'onClick' | 'type' | 'disabled'>) { + const router = useRouter() + const [loading, setLoading] = useState(false) + + const onLogout = async () => { + if (loading) return + setLoading(true) + const supabase = createClient() + await supabase.auth.signOut() + router.replace('/') + router.refresh() + } + + return ( + + ) +} diff --git a/components/navbar.tsx b/components/navbar.tsx index 25349f6..2a3e204 100644 --- a/components/navbar.tsx +++ b/components/navbar.tsx @@ -6,7 +6,7 @@ import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { useEffect, useState } from 'react' -import { logoutAction } from '@/actions/auth' +import { LogoutButton } from '@/components/logout-button' import { createClient } from '@/lib/supabase/client' import { cn } from '@/lib/utils' @@ -162,19 +162,16 @@ export function Navbar() { Mi espacio )} -
- -
+ + Salir + ) : ( <> @@ -242,14 +239,9 @@ export function Navbar() { > {isStaff ? 'Panel' : 'Mi espacio'} -
- -
+ + Salir + ) : ( <> From bca46759d230bd9d869444edba2373f89d4d8bb4 Mon Sep 17 00:00:00 2001 From: Mateo Pavoni Date: Tue, 2 Jun 2026 15:24:18 -0300 Subject: [PATCH 07/22] =?UTF-8?q?feat(auth):=20flujo=20olvid=C3=A9=20mi=20?= =?UTF-8?q?contrase=C3=B1a=20=E2=80=94=20link=20en=20login=20+=20/auth/rec?= =?UTF-8?q?uperar=20(resetPasswordForEmail)=20+=20hash-handler=20captura?= =?UTF-8?q?=20type=3Drecovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 5 ++- actions/auth.ts | 19 ++++++++ app/auth/recuperar/page.tsx | 71 ++++++++++++++++++++++++++++++ components/hash-invite-handler.tsx | 6 ++- components/login-form.tsx | 6 +++ 5 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 app/auth/recuperar/page.tsx diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 44de41e..f2cec45 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -45,7 +45,8 @@ Migración desde sitio PHP MVC propio (en `client-assets/vimet/vimet/`) que corr | `app/auth/callback/route.ts` | Callback auth: intercambia `code` (PKCE) o `token_hash` (OTP), redirige a `next` | | `app/auth/confirmar/page.tsx` | Confirma invite via `token_hash` o implicit flow; redirige a nueva-contrasena | | `app/auth/nueva-contrasena/page.tsx` | Form para crear contraseña (invite + reset) | -| `components/hash-invite-handler.tsx` | Client component global: detecta hash invite y llama `setSession` + redirige | +| `app/auth/recuperar/page.tsx` | "Olvidé mi contraseña": pide email y dispara `resetPasswordForEmail` | +| `components/hash-invite-handler.tsx` | Client component global: detecta hash invite/recovery y llama `setSession` + redirige | | `app/terminos/page.tsx` | Términos de servicio | | `app/privacidad/page.tsx` | Política de privacidad | | `app/(paciente)/layout.tsx` | Layout área paciente: auth gating + subnav | @@ -105,7 +106,7 @@ Migración desde sitio PHP MVC propio (en `client-assets/vimet/vimet/`) que corr | `lib/supabase/middleware.ts` | Helper para refresh de session en middleware | | `lib/supabase/auth-helpers.ts` | `getUserAndProfile` / `requireAuth` / `requireStaff` | | `middleware.ts` | Auth middleware: protege /mis-*, /feedback-semanal, /turnos/*, /admin/* | -| `actions/auth.ts` | Server Actions: login, register, logout | +| `actions/auth.ts` | Server Actions: login, register, logout, nuevaContrasena, recuperarContrasena | | `actions/turnos.ts` | Server Actions: crear, cancelar, actualizar estado | | `actions/contacto.ts` | Server Action: enviar email contacto | | `actions/ficha.ts` | Upsert de ficha clínica | diff --git a/actions/auth.ts b/actions/auth.ts index 28ee304..a8fb7ed 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -118,6 +118,25 @@ export async function registerAction(_prev: unknown, formData: FormData): Promis return { ok: true } } +export async function recuperarContrasenaAction( + _prev: unknown, + formData: FormData, +): Promise { + const parsed = z.string().email().safeParse(formData.get('email')) + if (!parsed.success) { + return { error: 'Ingresá un email válido.' } + } + + const supabase = createClient() + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000' + await supabase.auth.resetPasswordForEmail(parsed.data, { + redirectTo: `${siteUrl}/auth/nueva-contrasena`, + }) + + // Siempre devolvemos ok: no revelamos si el email existe o no. + return { ok: true } +} + export async function nuevaContrasenaAction(_prev: unknown, formData: FormData): Promise { const password = String(formData.get('password') ?? '') const confirm = String(formData.get('confirm') ?? '') diff --git a/app/auth/recuperar/page.tsx b/app/auth/recuperar/page.tsx new file mode 100644 index 0000000..8876805 --- /dev/null +++ b/app/auth/recuperar/page.tsx @@ -0,0 +1,71 @@ +'use client' + +import Link from 'next/link' +import { useFormState, useFormStatus } from 'react-dom' + +import { recuperarContrasenaAction } from '@/actions/auth' +import { AuthShell } from '@/components/auth-shell' + +function SubmitButton() { + const { pending } = useFormStatus() + return ( + + ) +} + +export default function RecuperarPage() { + const [state, formAction] = useFormState(recuperarContrasenaAction, {}) + + return ( + + ¿Te acordaste?{' '} + + Iniciar sesión + + + } + > + {state.ok ? ( +
+ Si existe una cuenta con ese email, te enviamos un link para restablecer la contraseña. + Revisá tu casilla (y la carpeta de spam). +
+ ) : ( +
+ {state.error && ( +
+ {state.error} +
+ )} +
+ + +
+ + + )} +
+ ) +} diff --git a/components/hash-invite-handler.tsx b/components/hash-invite-handler.tsx index 8fe464c..e4d8e61 100644 --- a/components/hash-invite-handler.tsx +++ b/components/hash-invite-handler.tsx @@ -5,14 +5,16 @@ import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' -// Detecta tokens de invitación en el hash (#access_token=...&type=invite). +// Detecta tokens de invitación o recuperación en el hash +// (#access_token=...&type=invite | #access_token=...&type=recovery). // @supabase/ssr no procesa el hash automáticamente, hay que parsear y setSession manualmente. +// En ambos casos el destino es /auth/nueva-contrasena para crear/cambiar la contraseña. export function HashInviteHandler() { const router = useRouter() useEffect(() => { const hash = window.location.hash.substring(1) - if (!hash.includes('type=invite')) return + if (!hash.includes('type=invite') && !hash.includes('type=recovery')) return const params = new URLSearchParams(hash) const accessToken = params.get('access_token') diff --git a/components/login-form.tsx b/components/login-form.tsx index 5726a0a..0000f0b 100644 --- a/components/login-form.tsx +++ b/components/login-form.tsx @@ -1,6 +1,7 @@ 'use client' import { LogIn } from 'lucide-react' +import Link from 'next/link' import { useFormState, useFormStatus } from 'react-dom' import { loginAction, type AuthState } from '@/actions/auth' @@ -56,6 +57,11 @@ export function LoginForm() { placeholder="Tu contraseña" className="w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-vimet-orange/40 focus:border-vimet-orange" /> +
+ + ¿Olvidaste tu contraseña? + +
From 5b9b0c3e13ecf27dcf24e2e3c1f105cff4fd5818 Mon Sep 17 00:00:00 2001 From: Mateo Pavoni Date: Tue, 2 Jun 2026 15:25:57 -0300 Subject: [PATCH 08/22] =?UTF-8?q?feat(registro):=20placeholders=20en=20tod?= =?UTF-8?q?os=20los=20campos=20del=20form=20(nombre,=20apellido,=20email,?= =?UTF-8?q?=20contrase=C3=B1as)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/register-form.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/components/register-form.tsx b/components/register-form.tsx index bc716e7..b8f4116 100644 --- a/components/register-form.tsx +++ b/components/register-form.tsx @@ -53,10 +53,17 @@ export function RegisterForm() { ) : null}
- - + +
- + setPassword(v)} /> @@ -80,6 +88,7 @@ export function RegisterForm() { type="password" minLength={6} required + placeholder="Repetí la contraseña" value={confirm} onChange={(v) => setConfirm(v)} ariaInvalid={mismatch} From bce7093d1c65d816d51776b7c5c21ca33d19679a Mon Sep 17 00:00:00 2001 From: Mateo Pavoni Date: Fri, 5 Jun 2026 10:18:23 -0300 Subject: [PATCH 09/22] =?UTF-8?q?fix(auth):=20bloquear=20submit=20de=20reg?= =?UTF-8?q?istro=20si=20las=20contrase=C3=B1as=20no=20coinciden=20+=20link?= =?UTF-8?q?=20'Volver=20al=20sitio'=20en=20pantallas=20de=20auth;=20guard?= =?UTF-8?q?=20defensivo=20state=3F.error=20en=20recurso-form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/auth-shell.tsx | 11 ++++++++++- components/register-form.tsx | 16 +++++++++++----- components/seguimiento/recurso-form.tsx | 4 ++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/components/auth-shell.tsx b/components/auth-shell.tsx index ea7a15a..a8b3854 100644 --- a/components/auth-shell.tsx +++ b/components/auth-shell.tsx @@ -1,3 +1,4 @@ +import { ArrowLeft } from 'lucide-react' import Link from 'next/link' import { type ReactNode } from 'react' @@ -15,7 +16,14 @@ export function AuthShell({ return (
-
+
+ + Volver al sitio + +
{footer}
+
diff --git a/components/register-form.tsx b/components/register-form.tsx index b8f4116..95d9844 100644 --- a/components/register-form.tsx +++ b/components/register-form.tsx @@ -8,13 +8,13 @@ import { registerAction, type AuthState } from '@/actions/auth' const initialState: AuthState = {} -function SubmitButton() { +function SubmitButton({ disabled }: { disabled?: boolean }) { const { pending } = useFormStatus() return (