diff --git a/README.md b/README.md index cb303a3..2532a4e 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ Health: `GET /api/health` - [docs/IDENTITY.md](./docs/IDENTITY.md) - [SECURITY.md](./SECURITY.md) - [docs/CONVERGENCE.md](./docs/CONVERGENCE.md) -- [docs/RUNBOOK.md](./docs/RUNBOOK.md) -- [sql/rls_checklist.sql](./sql/rls_checklist.sql) +- [docs/OPERATOR.md](./docs/OPERATOR.md) — alta `marketing@4ventures.es` +- [docs/PUSH.md](./docs/PUSH.md) — push a Gitea con token ## Rutas diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md new file mode 100644 index 0000000..4ac04ba --- /dev/null +++ b/docs/OPERATOR.md @@ -0,0 +1,55 @@ +# Operador marketing@4ventures.es + +La contraseña del portal legado **no** vale en Supabase Auth (`invalid_credentials`). + +## Alta / reset en Studio + +1. Abre https://supabase.4ventures.es (Studio) con cuenta admin de infra. +2. **Authentication → Users → Add user** + - Email: `marketing@4ventures.es` + - Password: genera una fuerte (guárdala en el gestor de la agencia) + - Auto Confirm: sí +3. En el usuario → **User Metadata** / **App Metadata** (JSON), por ejemplo: + +```json +{ + "role": "admin", + "company": "4Ventures", + "slug": "4ventures-hub" +} +``` + +App metadata (preferible para rol): + +```json +{ + "role": "admin", + "tenant_id": "" +} +``` + +4. Si usáis `provision_client`, también podéis: + +```sql +select public.provision_client( + '4Ventures', + '4ventures-hub', + 'marketing@4ventures.es', + 'CONTRASEÑA_FUERTE_AQUI', + 'admin' +); +``` + +5. Login en https://app.4ventures.es/login con ese email/password. + +## Rotación de API keys (obligatorio) + +Settings → API → Regenerate `anon` y `service_role`. +Actualizar en Dokploy env del contenedor `app` (y n8n si las usa). +Nunca volver a pegar keys en PDFs ni `.env.example`. + +## Verificación + +- Rol `admin` → ve `/dashboard/agencia` +- Rol `client` → redirigido fuera de agencia +- Chat/simulate solo con sesión diff --git a/docs/PUSH.md b/docs/PUSH.md new file mode 100644 index 0000000..8f92880 --- /dev/null +++ b/docs/PUSH.md @@ -0,0 +1,32 @@ +# Push a Gitea (bloqueado sin token) + +HTTPS a `git.4ventures.es` exige autenticación. SSH :22 hace timeout (solo HTTPS público). + +## Crear token (1 vez) + +1. Entra en https://git.4ventures.es (usuario con acceso a `Marketing/4ventures-client-portal`). +2. Settings → Applications → Generate New Token + - Nombre: `cursor-push` + - Scopes: `write:repository` +3. Copia el token (solo se muestra una vez). + +## Push de la rama de seguridad + +```bash +cd /Users/4ventures/4ventures-client-portal +git checkout harden/auth-tenant-security + +# Opción A — URL con usuario (el SO pedirá contraseña = el token) +git push -u origin harden/auth-tenant-security + +# Opción B — token en URL (no dejarlo en historial de shell si puedes evitarlo) +# git push -u https://USER:TOKEN@git.4ventures.es/Marketing/4ventures-client-portal.git HEAD +``` + +Usuario Gitea típico: el de Marcos / marketing (el que pueda escribir en Marketing). + +## Después del push + +1. Dokploy → servicio `app` → Redeploy / pull `harden/auth-tenant-security` o merge a `main`. +2. `curl -sf https://app.4ventures.es/api/health` +3. Login con operador aprovisionado (ver `docs/OPERATOR.md`). diff --git a/sql/creatives.sql b/sql/creatives.sql new file mode 100644 index 0000000..f2c8df5 --- /dev/null +++ b/sql/creatives.sql @@ -0,0 +1,48 @@ +-- Creatives multi-tenant (Supabase) +-- Run in SQL editor after reviewing names against existing schema. + +create table if not exists public.creatives ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + title text not null, + status text not null default 'draft' + check (status in ('draft', 'in_review', 'approved', 'rejected')), + mime_type text, + storage_path text, + bytes int, + sha256 text, + created_by uuid references auth.users(id), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists creatives_tenant_idx on public.creatives (tenant_id, updated_at desc); + +alter table public.creatives enable row level security; + +-- Adjust JWT claim path to match your Auth Hooks +create policy creatives_tenant_select on public.creatives + for select using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy creatives_tenant_insert on public.creatives + for insert with check ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +create policy creatives_tenant_update on public.creatives + for update using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +-- Storage bucket suggestion: creatives (private), path = {tenant_id}/{id} diff --git a/src/app/api/creatives/route.ts b/src/app/api/creatives/route.ts new file mode 100644 index 0000000..ee5e290 --- /dev/null +++ b/src/app/api/creatives/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; + +export async function GET() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { data, error } = await supabase + .from('creatives') + .select('id,title,status,mime_type,bytes,created_at,updated_at') + .order('updated_at', { ascending: false }); + + if (error) { + // Table may not exist yet — soft message for pre-migration + return NextResponse.json( + { creatives: [], warning: error.message }, + { status: 200 } + ); + } + + return NextResponse.json({ creatives: data ?? [] }); +} + +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const tenantId = + user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null; + if (!tenantId) { + return NextResponse.json({ error: 'Usuario sin tenant_id' }, { status: 403 }); + } + + const body = await req.json().catch(() => ({})); + const title = typeof body.title === 'string' ? body.title.trim() : ''; + if (!title) { + return NextResponse.json({ error: 'title requerido' }, { status: 400 }); + } + + const { data, error } = await supabase + .from('creatives') + .insert({ + tenant_id: tenantId, + title, + status: 'draft', + created_by: user.id, + }) + .select('id,title,status,created_at') + .single(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ creative: data }, { status: 201 }); +}