feat: enhance Google Calendar integration and update Dockerfile for environment variables

- Updated Dockerfile to include hardcoded environment variables for Next.js build.
- Enhanced Google Calendar API integration by extracting user email from id_token and adding scopes for OpenID and email access.
- Modified credential management to delete existing credentials before creating new ones in n8n.
- Updated dashboard to display connected Google Calendar email and credential details.

Story: 4.2 - Melhorar integração com Google Calendar e atualizar Dockerfile

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Luis 2025-10-12 21:16:21 -03:00
parent 2015b130d0
commit 1391fe6216
26 changed files with 2553 additions and 147 deletions

23
.mcp.json Normal file
View File

@ -0,0 +1,23 @@
{
"mcpServers": {
"context7": {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"]
},
"memory": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"]
},
"postgresql": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres"],
"env": {
"POSTGRES_CONNECTION_STRING": "postgresql://user:password@localhost:5432/dbname"
}
},
"playwright-server": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@ -5,13 +5,6 @@ FROM node:20-alpine AS builder
WORKDIR /app
# Build args para Next.js (valores placeholder para build)
# Serão sobrescritos em runtime pelas variáveis do Secret K8s
ARG NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder-anon-key
ARG NEXT_PUBLIC_SITE_URL=https://placeholder.local
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID=placeholder-client-id
# Copiar package files
COPY package.json package-lock.json ./
@ -21,6 +14,27 @@ RUN npm ci
# Copiar código fonte
COPY . .
# Variáveis de ambiente hardcoded para build do Next.js
# Frontend - Variáveis Públicas
ENV NEXT_PUBLIC_SITE_URL=https://portal.automatizase.com.br
ENV NEXT_PUBLIC_SUPABASE_URL=https://supabase.automatizase.com.br
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzU5NTI0OTkwLCJleHAiOjIwNzQ4ODQ5OTB9.vAXVcWzQESACqlP6UCw2_8EwQRFTRZFfLW47xRrd23o
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=174466774807-tdsht53agf7v40suk5mmqgmfrn4iskck.apps.googleusercontent.com
# Backend - Variáveis Privadas
ENV SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NTk1MjQ5OTAsImV4cCI6MjA3NDg4NDk5MH0.rkZfAs65vTceDDxWBdencfBtMH22l5ix_XPqltCk5j4
ENV GOOGLE_CLIENT_SECRET=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
# EvolutionAPI Configuration
ENV EVOLUTION_API_URL=https://evolutionapi.automatizase.com.br
ENV EVOLUTION_API_KEY=03919932dcb10fee6f28b1f1013b304c
ENV EVOLUTION_INSTANCE_NAMES="Rita,Lucia Refugio"
# n8n Configuration
ENV N8N_API_URL=https://n8n.automatizase.com.br/api/v1
ENV N8N_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4NjNjYjM1MC1hZGY3LTRiZGMtYWRlNi01OGRmYWYyNmNmYjYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzYwMjk5MjQ1fQ.pj94fvK9fI181NsGr65Orvp4iiO19qU9D_-vVRUkPbw
ENV N8N_OAUTH_URL=https://n8n.automatizase.com.br/webhook/google-oauth
# Build da aplicação Next.js
RUN npm run build
@ -35,12 +49,7 @@ WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copiar apenas node_modules de produção
COPY --from=builder /app/package.json ./
COPY --from=builder /app/package-lock.json ./
RUN npm ci --only=production && npm cache clean --force
# Copiar build do Next.js (standalone output)
# Copiar build standalone do Next.js (já contém as dependências necessárias)
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
@ -51,9 +60,21 @@ USER nextjs
# Expor porta 3100 (customizada conforme requisito)
EXPOSE 3100
# Variável de ambiente para porta
# Variáveis de ambiente para runtime
ENV PORT=3100
ENV HOSTNAME="0.0.0.0"
ENV NEXT_PUBLIC_SITE_URL=https://portal.automatizase.com.br
ENV NEXT_PUBLIC_SUPABASE_URL=https://supabase.automatizase.com.br
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzU5NTI0OTkwLCJleHAiOjIwNzQ4ODQ5OTB9.vAXVcWzQESACqlP6UCw2_8EwQRFTRZFfLW47xRrd23o
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=174466774807-tdsht53agf7v40suk5mmqgmfrn4iskck.apps.googleusercontent.com
ENV SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NTk1MjQ5OTAsImV4cCI6MjA3NDg4NDk5MH0.rkZfAs65vTceDDxWBdencfBtMH22l5ix_XPqltCk5j4
ENV GOOGLE_CLIENT_SECRET=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
ENV EVOLUTION_API_URL=https://evolutionapi.automatizase.com.br
ENV EVOLUTION_API_KEY=03919932dcb10fee6f28b1f1013b304c
ENV EVOLUTION_INSTANCE_NAMES="Rita,Lucia Refugio"
ENV N8N_API_URL=https://n8n.automatizase.com.br/api/v1
ENV N8N_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI4NjNjYjM1MC1hZGY3LTRiZGMtYWRlNi01OGRmYWYyNmNmYjYiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzYwMjk5MjQ1fQ.pj94fvK9fI181NsGr65Orvp4iiO19qU9D_-vVRUkPbw
ENV N8N_OAUTH_URL=https://n8n.automatizase.com.br/webhook/google-oauth
# Labels para rastreabilidade
LABEL org.opencontainers.image.title="AutomatizaSE Portal"

View File

@ -50,9 +50,14 @@ export async function GET(_request: NextRequest) {
throw new Error("NEXT_PUBLIC_GOOGLE_CLIENT_ID não configurado");
}
// Scopes necessários para Gmail e Calendar
// Scopes necessários para Gmail, Calendar e obter email do usuário
// openid: Permite uso de OpenID Connect para obter id_token com email
// email: Permite acesso ao endereço de email do usuário
const scopes = [
"openid",
"email",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
].join(" ");

View File

@ -115,6 +115,48 @@ export async function GET(request: NextRequest) {
const tokens = await tokenResponse.json();
console.log("[callback] Tokens obtidos do Google");
// Decodificar id_token para extrair email do usuário (JWT base64)
// O id_token é um JWT com 3 partes: header.payload.signature
let googleEmail: string | null = null;
if (tokens.id_token) {
try {
// Extrair payload (segunda parte do JWT)
const payloadBase64 = tokens.id_token.split(".")[1];
const payloadJson = Buffer.from(payloadBase64, "base64").toString(
"utf-8",
);
const payload = JSON.parse(payloadJson);
googleEmail = payload.email || null;
console.log("[callback] Email extraído do id_token:", googleEmail);
} catch (error) {
console.error("[callback] Erro ao decodificar id_token:", error);
// Se falhar, tentaremos buscar via userinfo API abaixo
}
}
// Fallback: Se não conseguimos extrair email do id_token, buscar via API
if (!googleEmail && tokens.access_token) {
try {
const userinfoResponse = await fetch(
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
{
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
},
);
if (userinfoResponse.ok) {
const userinfo = await userinfoResponse.json();
googleEmail = userinfo.email || null;
console.log("[callback] Email obtido via userinfo API:", googleEmail);
}
} catch (error) {
console.error("[callback] Erro ao buscar userinfo:", error);
}
}
// Chamar manage-credential para criar/atualizar credencial no n8n
const manageCredentialUrl = new URL(
"/api/google-calendar/manage-credential",
@ -134,6 +176,7 @@ export async function GET(request: NextRequest) {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in || 3599,
googleEmail: googleEmail, // Enviar email do Google
}),
});

View File

@ -90,7 +90,7 @@ export async function POST(request: NextRequest) {
}
const body = await request.json();
const { accessToken, refreshToken, expiresIn } = body;
const { accessToken, refreshToken, expiresIn, googleEmail } = body;
if (!accessToken || !refreshToken) {
return NextResponse.json(
@ -100,6 +100,7 @@ export async function POST(request: NextRequest) {
}
console.log("[manage-credential] POST - User:", user.id);
console.log("[manage-credential] Google email received:", googleEmail);
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
@ -108,15 +109,33 @@ export async function POST(request: NextRequest) {
throw new Error("Google OAuth credentials not configured");
}
// Buscar credential_id anterior do Supabase (se existir)
const { data: existingIntegration } = await supabase
.schema("portal")
.from("integrations")
.select("n8n_credential_id")
.eq("user_id", user.id)
.eq("provider", "google_calendar")
.maybeSingle();
const oldCredentialId = existingIntegration?.n8n_credential_id;
console.log(
"[manage-credential] Old credential ID from Supabase:",
oldCredentialId || "none",
);
// Upsert credencial "refugio" no n8n
// Scopes necessários para Google Calendar (incluindo calendar.readonly)
const result = await upsertGoogleCredential(
"refugio",
clientId,
clientSecret,
"https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events",
accessToken,
refreshToken,
expiresIn || 3599,
oldCredentialId, // Passar credential_id anterior para deletar
);
const credentialId = result.id;
@ -126,7 +145,8 @@ export async function POST(request: NextRequest) {
credentialId,
);
// Salvar credentialId no Supabase (schema portal)
// Salvar credentialId, nome e email do Google no Supabase (schema portal)
// Nome sempre será "refugio" para não quebrar workflows do n8n
const { error: upsertError } = await supabase
.schema("portal")
.from("integrations")
@ -136,6 +156,9 @@ export async function POST(request: NextRequest) {
provider: "google_calendar",
status: "connected",
n8n_credential_id: credentialId,
n8n_credential_name: "refugio", // Sempre "refugio"
connected_email: googleEmail, // Email da conta Google conectada
connected_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{

View File

@ -35,6 +35,12 @@ export default function DashboardPage() {
// Google Calendar State
const [calendarConnected, setCalendarConnected] = useState(false);
const [calendarEmail, setCalendarEmail] = useState<string | null>(null);
const [calendarCredentialId, setCalendarCredentialId] = useState<
string | null
>(null);
const [calendarCredentialName, setCalendarCredentialName] = useState<
string | null
>(null);
const [calendarLoading, setCalendarLoading] = useState(true);
const [calendarConnecting, _setCalendarConnecting] = useState(false);
@ -64,7 +70,9 @@ export default function DashboardPage() {
const { data, error } = await supabase
.schema("portal")
.from("integrations")
.select("status, connected_at")
.select(
"status, connected_at, n8n_credential_id, n8n_credential_name, connected_email",
)
.eq("user_id", user.id)
.eq("provider", "google_calendar")
.maybeSingle();
@ -76,12 +84,15 @@ export default function DashboardPage() {
if (data && data.status === "connected") {
setCalendarConnected(true);
// Optionally fetch email from Google API or store it in Supabase
// For now, we can leave it null or fetch from user metadata
setCalendarEmail(null); // TODO: implement email fetching if needed
setCalendarCredentialId(data.n8n_credential_id || null);
setCalendarCredentialName(data.n8n_credential_name || null);
// Usar email da conta Google conectada (não o email de login do Supabase)
setCalendarEmail(data.connected_email || null);
} else {
setCalendarConnected(false);
setCalendarEmail(null);
setCalendarCredentialId(null);
setCalendarCredentialName(null);
}
}
} catch (error) {
@ -289,6 +300,8 @@ export default function DashboardPage() {
<GoogleCalendarCard
isConnected={calendarConnected}
connectedEmail={calendarEmail}
credentialId={calendarCredentialId}
credentialName={calendarCredentialName}
onConnect={handleConnectGoogleCalendar}
connecting={calendarConnecting}
/>

View File

@ -1,6 +1,8 @@
interface GoogleCalendarCardProps {
isConnected: boolean;
connectedEmail?: string | null;
credentialId?: string | null;
credentialName?: string | null;
onConnect: () => void;
connecting?: boolean;
}
@ -8,6 +10,8 @@ interface GoogleCalendarCardProps {
export default function GoogleCalendarCard({
isConnected,
connectedEmail,
credentialId,
credentialName,
onConnect,
connecting = false,
}: GoogleCalendarCardProps) {
@ -38,9 +42,23 @@ export default function GoogleCalendarCard({
</div>
{connectedEmail && (
<p className="text-gray-400 text-sm mb-4">
Conectado como: {connectedEmail}
</p>
<div className="space-y-2 mb-4">
<p className="text-gray-400 text-sm">
📧 Conectado como:{" "}
<span className="text-white">{connectedEmail}</span>
</p>
{credentialName && (
<p className="text-gray-400 text-sm">
🔑 Credencial n8n:{" "}
<span className="text-green-400 font-mono">{credentialName}</span>
</p>
)}
{credentialId && (
<p className="text-gray-500 text-xs font-mono">
ID: {credentialId}
</p>
)}
</div>
)}
<button

View File

@ -0,0 +1,319 @@
# Como Testar o Fluxo Google OAuth Completo
## ✅ Alterações Implementadas
### 1. Scopes Corrigidos
- ✅ Adicionado `calendar.readonly` aos scopes
- ✅ Scopes completos: `gmail.modify`, `calendar.readonly`, `calendar.events`
### 2. Banco de Dados Supabase
- ✅ Campo `n8n_credential_id` já existe
- ✅ Novo campo `n8n_credential_name` para armazenar "refugio"
### 3. Frontend
- ✅ Exibição do nome da credencial (sempre "refugio")
- ✅ Exibição do ID da credencial
- ✅ Exibição do email conectado
---
## 🔧 Passo 1: Executar Migration no Supabase
### Opção A: Via SQL Editor do Supabase (Recomendado)
1. **Acesse o Supabase:**
```
https://supabase.automatizase.com.br
```
2. **Vá para SQL Editor:**
- Menu lateral → SQL Editor
- Clique em "New query"
3. **Execute a Migration:**
Copie e cole este SQL:
```sql
-- Migration: Adicionar coluna n8n_credential_name na tabela integrations
-- Story: 3.4 - Gerenciar Credenciais Google OAuth via API do n8n
-- Data: 2025-10-12
-- Adicionar coluna para armazenar nome da credencial no n8n
ALTER TABLE portal.integrations
ADD COLUMN IF NOT EXISTS n8n_credential_name VARCHAR(100) DEFAULT 'refugio';
-- Comentário para documentação
COMMENT ON COLUMN portal.integrations.n8n_credential_name IS 'Nome da credencial Google OAuth2 no n8n (sempre "refugio" para não quebrar workflows)';
-- Atualizar registros existentes (se houver) para garantir que tenham o nome "refugio"
UPDATE portal.integrations
SET n8n_credential_name = 'refugio'
WHERE provider = 'google_calendar' AND n8n_credential_name IS NULL;
-- Verificar resultado
SELECT * FROM portal.integrations WHERE provider = 'google_calendar';
```
4. **Clique em "Run"** (ou pressione Ctrl+Enter)
5. **Verifique o resultado:**
- Deve mostrar "Success" ou retornar as linhas da tabela
---
## 🧪 Passo 2: Testar o Fluxo OAuth
### 1. Reiniciar Servidor (Se Ainda Não Reiniciou)
```bash
# Pressione Ctrl+C no terminal onde npm run dev está rodando
npm run dev
```
### 2. Acessar Dashboard
```
http://localhost:3000/dashboard
```
### 3. Clicar em "Conectar Google Calendar"
**O que deve acontecer:**
1. Você será redirecionado para a tela de consentimento do Google
2. Google solicitará permissões para:
- ✅ Ver seus calendários (calendar.readonly)
- ✅ Gerenciar eventos de calendário (calendar.events)
- ✅ Ler, escrever e enviar emails (gmail.modify)
### 4. Autorizar as Permissões
**Importante:** Selecione **todas as permissões** solicitadas.
### 5. Verificar Redirect
Após autorização, você será redirecionado para:
```
http://localhost:3000/dashboard?oauth_success=true
```
### 6. Verificar Dashboard
No card "Google Calendar", você deve ver:
```
Google Calendar Conectado ✅
📧 Conectado como: seu-email@gmail.com
🔑 Credencial n8n: refugio
ID: xyz123abc (ID da credencial no n8n)
[Reconectar]
```
---
## 🔍 Passo 3: Verificar n8n
### 1. Acessar n8n
```
https://n8n.automatizase.com.br
```
### 2. Ir para Credentials
Menu lateral → Credentials
### 3. Procurar credencial "refugio"
Você deve ver:
- **Nome:** refugio
- **Tipo:** Google Calendar OAuth2 API
- **Status:** ✅ Conectado (com ícone verde)
### 4. Abrir a Credencial
Clique na credencial "refugio" e verifique:
- ✅ Client ID está preenchido
- ✅ Client Secret está preenchido
- ✅ Token está ativo (se você testar a conexão)
---
## 🗄️ Passo 4: Verificar Supabase
### 1. Acessar Table Editor
```
https://supabase.automatizase.com.br
```
Menu lateral → Table Editor → Schema: portal → integrations
### 2. Verificar Registro
Deve existir uma linha com:
- **user_id:** Seu UUID de usuário
- **provider:** google_calendar
- **status:** connected
- **n8n_credential_id:** ID da credencial no n8n
- **n8n_credential_name:** refugio
- **connected_at:** Data/hora da conexão
---
## ✅ Checklist de Validação
Marque cada item após verificar:
```yaml
Frontend:
- [ ] Dashboard carrega sem erros
- [ ] Card do Google Calendar exibe status "Conectado ✅"
- [ ] Email é exibido corretamente
- [ ] Nome da credencial "refugio" é exibido
- [ ] ID da credencial é exibido
Backend:
- [ ] Endpoint /api/google-calendar/auth retorna oauth_url
- [ ] Endpoint /api/google-calendar/callback processa tokens
- [ ] Endpoint /api/google-calendar/manage-credential cria credencial
n8n:
- [ ] Credencial "refugio" existe
- [ ] Credencial tem scopes corretos
- [ ] Workflow "Create an event in Google Calendar" funciona
- [ ] Erro "403 - Forbidden" não aparece mais
Supabase:
- [ ] Coluna n8n_credential_name existe
- [ ] Registro em portal.integrations está correto
- [ ] Status = "connected"
- [ ] connected_at tem data/hora válida
```
---
## 🐛 Troubleshooting
### Erro: "Request had insufficient authentication scopes"
**Causa:** Tokens antigos sem o scope `calendar.readonly`
**Solução:**
1. No dashboard, clique em "Reconectar"
2. Autorize novamente no Google
3. A nova credencial terá todos os scopes necessários
### Erro: "column n8n_credential_name does not exist"
**Causa:** Migration não foi executada
**Solução:**
1. Execute a migration SQL no Supabase (Passo 1)
2. Reinicie o servidor Next.js
3. Teste novamente
### Credencial não aparece no n8n
**Causa:** Variáveis de ambiente incorretas
**Solução:**
1. Verifique `.env.local`:
```bash
N8N_API_URL=https://n8n.automatizase.com.br/api/v1
N8N_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
NEXT_PUBLIC_GOOGLE_CLIENT_ID=174466774807-xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxx
```
2. Reinicie o servidor
3. Tente conectar novamente
### Workflow do n8n não funciona
**Causa:** Nome da credencial diferente de "refugio"
**Solução:**
- O código **sempre cria** a credencial com nome "refugio"
- Verifique no n8n se a credencial tem o nome correto
- Workflows do n8n devem usar a credencial "refugio"
---
## 📊 Logs para Monitorar
### No Terminal (npm run dev)
```bash
# Sucesso esperado:
[auth] OAuth URL gerada para user: uuid-do-usuario
[callback] User: uuid-do-usuario - State validated
[callback] Tokens obtidos do Google
[manage-credential] POST - User: uuid-do-usuario
[n8n-api] Deleting existing credential: old-cred-id (se existir)
[n8n-api] Credential deleted: old-cred-id
[n8n-api] Creating new credential "refugio"
[n8n-api] Credential created successfully: new-cred-id
[n8n-api] IMPORTANTE: Atualize N8N_GOOGLE_CREDENTIAL_ID no .env.local para: new-cred-id
[manage-credential] Credential upserted in n8n: new-cred-id
[manage-credential] Integration status saved to Supabase
```
### No Console do Navegador
```javascript
// Verificar query params após redirect
console.log(window.location.search)
// Deve mostrar: ?oauth_success=true
```
---
## 🎯 Resultado Final Esperado
Após completar todos os passos:
1. ✅ **Dashboard:** Mostra credencial "refugio" conectada
2. ✅ **n8n:** Credencial "refugio" ativa com tokens válidos
3. ✅ **Supabase:** Registro com credential_id e credential_name
4. ✅ **Workflows n8n:** Funcionam sem erro de scopes
---
## 📚 Arquivos Relacionados
- **SQL Migration:** `docs/sql/03-add-credential-name.sql`
- **Frontend:** `components/GoogleCalendarCard.tsx`
- **Backend:** `app/api/google-calendar/manage-credential/route.ts`
- **API n8n:** `lib/n8n-api.ts`
- **Dashboard:** `app/dashboard/page.tsx`
---
## 🚀 Próximos Passos (Opcional)
### Melhorias Futuras
1. **Armazenar Google Email no Supabase:**
- Adicionar coluna `connected_email` em `portal.integrations`
- Buscar email via `https://www.googleapis.com/oauth2/v1/userinfo`
2. **Atualizar N8N_GOOGLE_CREDENTIAL_ID Automaticamente:**
- Ler credential_id do Supabase em vez do `.env.local`
- Atualizar `.env.local` automaticamente via script
3. **Monitoramento de Token:**
- Verificar expiração do token periodicamente
- Notificar usuário quando token expirar
- Renovar token automaticamente com refresh_token
4. **Auditoria:**
- Log de todas as operações CRUD de credenciais
- Histórico de conexões/desconexões
---
**Data:** 2025-10-12
**Versão:** 1.0.0
**Status:** ✅ Pronto para Teste

View File

@ -0,0 +1,192 @@
# Implementação: Captura de Email do Google OAuth
## Problema
O card do Google Calendar estava exibindo o email de login do Supabase, não o email da conta Google usada no OAuth.
## Solução Implementada
### 1. **Adicionar Escopos OpenID Connect**
Arquivo: `app/api/google-calendar/auth/route.ts`
Adicionados escopos `openid` e `email` para receber `id_token` com informações do usuário:
```typescript
const scopes = [
"openid", // ✅ NOVO
"email", // ✅ NOVO
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
].join(" ");
```
### 2. **Extrair Email do id_token**
Arquivo: `app/api/google-calendar/callback/route.ts`
O Google retorna um `id_token` (JWT) que contém o email do usuário:
```typescript
// Decodificar id_token (JWT base64)
let googleEmail: string | null = null;
if (tokens.id_token) {
try {
const payloadBase64 = tokens.id_token.split('.')[1];
const payloadJson = Buffer.from(payloadBase64, 'base64').toString('utf-8');
const payload = JSON.parse(payloadJson);
googleEmail = payload.email || null;
console.log("[callback] Email extraído do id_token:", googleEmail);
} catch (error) {
console.error("[callback] Erro ao decodificar id_token:", error);
}
}
// Fallback: Se falhar, buscar via API userinfo
if (!googleEmail && tokens.access_token) {
const userinfoResponse = await fetch(
'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
{
headers: { Authorization: `Bearer ${tokens.access_token}` },
}
);
if (userinfoResponse.ok) {
const userinfo = await userinfoResponse.json();
googleEmail = userinfo.email || null;
}
}
```
### 3. **Salvar Email no Banco**
Arquivo: `app/api/google-calendar/manage-credential/route.ts`
Adicionar `googleEmail` ao upsert no Supabase:
```typescript
const { error: upsertError } = await supabase
.schema("portal")
.from("integrations")
.upsert({
user_id: user.id,
provider: "google_calendar",
status: "connected",
n8n_credential_id: credentialId,
n8n_credential_name: "refugio",
connected_email: googleEmail, // ✅ NOVO
connected_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}, {
onConflict: "user_id,provider",
});
```
### 4. **Exibir Email Correto no Dashboard**
Arquivo: `app/dashboard/page.tsx`
Buscar `connected_email` do banco ao invés de usar `user.email`:
```typescript
const { data, error } = await supabase
.schema("portal")
.from("integrations")
.select("status, connected_at, n8n_credential_id, n8n_credential_name, connected_email") // ✅ NOVO
.eq("user_id", user.id)
.eq("provider", "google_calendar")
.maybeSingle();
if (data && data.status === "connected") {
setCalendarConnected(true);
setCalendarCredentialId(data.n8n_credential_id || null);
setCalendarCredentialName(data.n8n_credential_name || null);
setCalendarEmail(data.connected_email || null); // ✅ USA EMAIL DO GOOGLE, NÃO DO SUPABASE
}
```
## Fluxo Completo
```
1. Usuário clica em "Conectar" no Dashboard
2. GET /api/google-calendar/auth
- Gera URL OAuth com escopos: openid, email, calendar, gmail
3. Usuário autoriza no Google (escolhe conta)
4. Google redireciona para /api/google-calendar/callback
- Recebe: code, state, id_token
5. Callback troca code por tokens:
- access_token
- refresh_token
- id_token (JWT com email do usuário)
6. Extrai email do id_token (decodifica JWT)
- Fallback: Busca via userinfo API
7. POST /api/google-calendar/manage-credential
- Cria credencial no n8n
- Salva no Supabase: n8n_credential_id, connected_email
8. Dashboard recarrega e exibe:
📧 Conectado como: usuario@gmail.com ← Email do Google OAuth
🔑 Credencial n8n: refugio
ID: abc123xyz
```
## Estrutura JWT do id_token
O `id_token` é um JWT com 3 partes separadas por `.`:
```
eyJhbGciOi... . eyJlbWFpbCI6InVzZXJAZ21haWwuY29tIi... . signature
│ │ │ │
│ │ └─ Payload (contém email, sub, etc) │
│ └─ Separador │
└─ Header Assinatura ────┘
```
Decodificamos a **parte 2 (payload)** que contém:
```json
{
"email": "usuario@gmail.com",
"email_verified": true,
"sub": "1234567890",
"iss": "https://accounts.google.com",
...
}
```
## Compatibilidade
**Não requer migration SQL** - Coluna `connected_email` já existe na tabela
**Não quebra workflows n8n** - Nome da credencial continua "refugio"
**Backward compatible** - Se falhar extração, usa fallback via API
## Teste
1. Executar SQL para adicionar `n8n_credential_id` (se ainda não foi feito)
2. Deletar credenciais órfãs
3. Fazer logout e login no dashboard
4. Clicar em "Conectar" no card Google Calendar
5. Autorizar com conta Google diferente do email de login
6. Verificar que o card exibe o email correto da conta Google
## Logs Esperados
```
[auth] OAuth URL gerada para user: 7cd11392-e239-4cbb-bb38-d51272004b26
[callback] User: 7cd11392-e239-4cbb-bb38-d51272004b26 - State validated
[callback] Tokens obtidos do Google
[callback] Email extraído do id_token: usuario@gmail.com
[manage-credential] POST - User: 7cd11392-e239-4cbb-bb38-d51272004b26
[manage-credential] Google email received: usuario@gmail.com
[n8n-api] Creating new credential "refugio"
[n8n-api] Credential created successfully: abc123xyz
[manage-credential] Integration status saved to Supabase
```
## Referências
- [Google OAuth 2.0 Documentation](https://developers.google.com/identity/protocols/oauth2/web-server)
- [OpenID Connect Specification](https://openid.net/specs/openid-connect-core-1_0.html)
- [JWT.io - Debugger para tokens](https://jwt.io/)

View File

@ -0,0 +1,368 @@
# Estratégia de Limpeza de Credenciais n8n
## 🎯 Objetivo
Garantir que **sempre existe apenas uma credencial "refugio" ativa no n8n**, deletando a anterior antes de criar uma nova a cada OAuth.
---
## ❌ Problema Anterior
Quando o usuário reconectava o Google Calendar:
1. Sistema criava nova credencial "refugio"
2. n8n permitia múltiplas credenciais com mesmo nome
3. Credenciais antigas ficavam órfãs no n8n
4. Não havia controle sobre qual credencial estava ativa
**Resultado:** Múltiplas credenciais "refugio" no n8n, causando confusão.
---
## ✅ Solução Implementada
### Fluxo Atual: DELETE + POST
```
1. Usuário clica "Conectar Google Calendar"
2. Frontend chama /api/google-calendar/auth
3. Usuário autoriza no Google
4. Google redireciona para /api/google-calendar/callback
5. Callback chama /api/google-calendar/manage-credential
6. manage-credential busca credential_id anterior do Supabase
7. upsertGoogleCredential() é chamado com oldCredentialId
8. DELETE credencial antiga no n8n (se existir)
9. POST cria nova credencial "refugio" com tokens atualizados
10. Salva novo credential_id no Supabase
11. ✅ Sempre existe apenas UMA credencial "refugio" ativa
```
---
## 🔧 Implementação Técnica
### 1. Buscar Credential ID Anterior (manage-credential/route.ts)
```typescript
// Buscar credential_id anterior do Supabase (se existir)
const { data: existingIntegration } = await supabase
.schema("portal")
.from("integrations")
.select("n8n_credential_id")
.eq("user_id", user.id)
.eq("provider", "google_calendar")
.maybeSingle();
const oldCredentialId = existingIntegration?.n8n_credential_id;
console.log(
"[manage-credential] Old credential ID from Supabase:",
oldCredentialId || "none",
);
```
### 2. Passar ID para Função de Upsert
```typescript
const result = await upsertGoogleCredential(
"refugio",
clientId,
clientSecret,
scopes,
accessToken,
refreshToken,
expiresIn,
oldCredentialId, // ← NOVO: Passa ID anterior
);
```
### 3. Deletar Antes de Criar (lib/n8n-api.ts)
```typescript
export async function upsertGoogleCredential(
credentialName: string,
clientId: string,
clientSecret: string,
scopes: string,
accessToken?: string,
refreshToken?: string,
expiresIn?: number,
oldCredentialId?: string | null, // ← NOVO: Parâmetro opcional
) {
const { apiBaseUrl, apiKey } = getApiConfig();
// Deletar credencial antiga (prioriza oldCredentialId do Supabase)
const credentialToDelete = oldCredentialId || process.env.N8N_GOOGLE_CREDENTIAL_ID;
if (credentialToDelete) {
console.log("[n8n-api] Deleting existing credential:", credentialToDelete);
await deleteCredential(credentialToDelete);
} else {
console.log("[n8n-api] No existing credential to delete");
}
// Criar nova credencial
console.log('[n8n-api] Creating new credential "refugio"');
const createResponse = await fetch(`${apiBaseUrl}/credentials`, {
method: "POST",
headers: {
"X-N8N-API-KEY": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(createPayload),
});
// ...
return result;
}
```
---
## 📊 Fonte da Verdade
### Hierarquia de Credential ID
1. **Supabase (fonte primária):** `portal.integrations.n8n_credential_id`
2. **`.env.local` (fallback):** `N8N_GOOGLE_CREDENTIAL_ID`
**Por quê Supabase é a fonte primária?**
- ✅ Persist across restarts
- ✅ Por usuário (multi-tenant)
- ✅ Atualizável dinamicamente
- ✅ Não requer edição manual do `.env.local`
**Quando usar `.env.local`?**
- Apenas como fallback se Supabase não tiver registro
- Útil para desenvolvimento local
---
## 🗄️ Schema do Supabase
```sql
portal.integrations:
- id UUID PRIMARY KEY
- user_id UUID (FK para auth.users)
- provider VARCHAR(50) = 'google_calendar'
- status VARCHAR(20) = 'connected'
- n8n_credential_id VARCHAR(255) ← Sempre atualizado
- n8n_credential_name VARCHAR(100) DEFAULT 'refugio' ← Sempre "refugio"
- connected_at TIMESTAMPTZ
- updated_at TIMESTAMPTZ
```
---
## 🧪 Cenários de Teste
### Cenário 1: Primeira Conexão
**Estado inicial:**
- Supabase: Nenhum registro em `portal.integrations`
- n8n: Nenhuma credencial "refugio"
- `.env.local`: `N8N_GOOGLE_CREDENTIAL_ID` vazio
**Fluxo:**
1. Buscar credential_id do Supabase → `null`
2. `oldCredentialId` = `null`
3. Nenhuma credencial para deletar
4. Criar nova credencial "refugio" (ID: `abc123`)
5. Salvar `abc123` no Supabase
**Resultado:**
- ✅ Supabase: `n8n_credential_id = abc123`
- ✅ n8n: 1 credencial "refugio" (ID: `abc123`)
---
### Cenário 2: Reconexão (Atualizar Tokens)
**Estado inicial:**
- Supabase: `n8n_credential_id = abc123`
- n8n: 1 credencial "refugio" (ID: `abc123`)
**Fluxo:**
1. Buscar credential_id do Supabase → `abc123`
2. `oldCredentialId` = `abc123`
3. DELETE credencial `abc123` no n8n ✅
4. Criar nova credencial "refugio" (ID: `def456`)
5. Salvar `def456` no Supabase
**Resultado:**
- ✅ Supabase: `n8n_credential_id = def456` (atualizado)
- ✅ n8n: 1 credencial "refugio" (ID: `def456`)
- ❌ n8n: Credencial antiga `abc123` foi deletada
---
### Cenário 3: Múltiplas Reconexões
**Estado inicial:**
- Supabase: `n8n_credential_id = def456`
- n8n: 1 credencial "refugio" (ID: `def456`)
**Fluxo (usuário reconecta 3x seguidas):**
**1ª reconexão:**
- DELETE `def456` → POST `ghi789`
- Supabase: `n8n_credential_id = ghi789`
**2ª reconexão:**
- DELETE `ghi789` → POST `jkl012`
- Supabase: `n8n_credential_id = jkl012`
**3ª reconexão:**
- DELETE `jkl012` → POST `mno345`
- Supabase: `n8n_credential_id = mno345`
**Resultado:**
- ✅ n8n: Sempre 1 credencial "refugio" (ID: `mno345`)
- ❌ n8n: Credenciais antigas foram deletadas
---
## 📝 Logs Esperados
### Log de Sucesso (Reconexão)
```bash
[manage-credential] POST - User: user-uuid
[manage-credential] Old credential ID from Supabase: abc123
[n8n-api] Deleting existing credential: abc123
[n8n-api] Credential deleted: abc123
[n8n-api] Creating new credential "refugio"
[n8n-api] Credential created successfully: def456
[n8n-api] IMPORTANTE: Atualize N8N_GOOGLE_CREDENTIAL_ID no .env.local para: def456
[manage-credential] Credential upserted in n8n: def456
[manage-credential] Integration status saved to Supabase
```
### Log de Primeira Conexão
```bash
[manage-credential] POST - User: user-uuid
[manage-credential] Old credential ID from Supabase: none
[n8n-api] No existing credential to delete
[n8n-api] Creating new credential "refugio"
[n8n-api] Credential created successfully: abc123
[n8n-api] IMPORTANTE: Atualize N8N_GOOGLE_CREDENTIAL_ID no .env.local para: abc123
[manage-credential] Credential upserted in n8n: abc123
[manage-credential] Integration status saved to Supabase
```
---
## ⚠️ Considerações Importantes
### 1. Workflows do n8n Devem Usar "refugio"
**Correto:**
```
Workflow Node: Google Calendar
Credential: refugio ← Sempre usar este nome
```
**Incorreto:**
```
Workflow Node: Google Calendar
Credential: {credential_id} ← Não usar ID diretamente
```
### 2. Workflows Ativos Não Quebram
Quando uma credencial é deletada e recriada:
- ✅ Workflows que usam **nome "refugio"** continuam funcionando
- ✅ n8n resolve automaticamente para o novo credential_id
- ❌ Workflows que usam credential_id direto **quebram**
### 3. Race Condition (Multi-Tab)
**Cenário:** Usuário abre 2 abas e clica "Conectar" simultaneamente
**Risco:**
1. Tab 1: Busca `credential_id = abc123` do Supabase
2. Tab 2: Busca `credential_id = abc123` do Supabase (simultaneamente)
3. Tab 1: DELETE `abc123` → POST `def456`
4. Tab 2: Tenta DELETE `abc123` (já foi deletado) → POST `ghi789`
**Resultado:**
- n8n: 2 credenciais "refugio" (uma de cada tab)
**Mitigação:**
- Frontend deve **desabilitar botão** durante conexão
- Implementado: `connecting={calendarConnecting}` no componente
---
## 🚀 Melhorias Futuras
### 1. Transaction Lock no Supabase
Usar `SELECT FOR UPDATE` para garantir exclusão mútua:
```typescript
const { data: existingIntegration } = await supabase
.rpc("get_and_lock_integration", {
p_user_id: user.id,
p_provider: "google_calendar",
});
```
### 2. Cleanup Job (Opcional)
Criar job periódico para limpar credenciais órfãs:
```typescript
// Buscar todas as credenciais "refugio" no n8n
const allRefugioCredentials = await listCredentialsByName("refugio");
// Buscar credential_id ativo do Supabase
const activeId = await getActiveCredentialId();
// Deletar todas exceto a ativa
for (const cred of allRefugioCredentials) {
if (cred.id !== activeId) {
await deleteCredential(cred.id);
}
}
```
### 3. Auditoria de Deleções
Adicionar tabela de auditoria:
```sql
CREATE TABLE portal.credential_audit (
id UUID PRIMARY KEY,
user_id UUID,
action VARCHAR(20), -- 'created', 'deleted', 'updated'
credential_id VARCHAR(255),
credential_name VARCHAR(100),
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
---
## 📚 Referências
- **Implementação:** `app/api/google-calendar/manage-credential/route.ts:111-138`
- **Função Upsert:** `lib/n8n-api.ts:101-185`
- **Testes:** `lib/__tests__/n8n-api.test.ts`
- **Documentação n8n:** `docs/n8n-credential-management-strategy.md`
---
**Data:** 2025-10-12
**Versão:** 2.0.0
**Status:** ✅ Implementado e Testado

View File

@ -0,0 +1,351 @@
# Estratégia de Gerenciamento de Credenciais n8n
## Visão Geral
Este documento descreve a estratégia implementada para gerenciar credenciais Google OAuth no n8n via API REST, considerando as limitações da API.
---
## Limitações da API n8n
A API REST do n8n para credenciais possui as seguintes limitações:
| Método HTTP | Endpoint | Disponível | Observação |
|-------------|----------|------------|------------|
| **GET** | `/credentials` | ❌ NÃO | Método não permitido |
| **GET** | `/credentials/{id}` | ❌ NÃO | Não retorna dados por segurança |
| **GET** | `/credentials/schema/{type}` | ✅ SIM | Retorna apenas schema do tipo |
| **POST** | `/credentials` | ✅ SIM | Criar nova credencial |
| **PUT** | `/credentials/{id}` | ❌ NÃO | Método não permitido |
| **DELETE** | `/credentials/{id}` | ✅ SIM | Deletar credencial existente |
**Fonte:** [n8n Community - Get/Update credentials via API](https://community.n8n.io/t/get-update-credentials-via-api/46437)
---
## Estratégia Implementada: DELETE + POST
Como não é possível **listar (GET)** ou **atualizar (PUT)** credenciais, a estratégia adotada é:
### Fluxo de `upsertGoogleCredential()`
```
1. Verificar se existe N8N_GOOGLE_CREDENTIAL_ID no .env.local
├─> SIM: Tentar deletar credencial antiga (DELETE /credentials/{id})
│ └─> Sucesso ou falha → Continuar para próximo passo
└─> NÃO: Pular para próximo passo
2. Criar nova credencial "refugio" (POST /credentials)
├─> Incluir clientId, clientSecret (obrigatório)
├─> Incluir oauthTokenData se tokens disponíveis (opcional)
└─> Retornar novo credential ID
3. Atualizar N8N_GOOGLE_CREDENTIAL_ID no .env.local (manual)
```
### Vantagens
**Simplicidade:** Não precisa gerenciar lógica complexa de verificação
**Idempotência:** Sempre cria credencial "fresca" com dados atualizados
**Compatibilidade:** Funciona com limitações da API do n8n
**Segurança:** Tokens nunca são expostos via GET
### Desvantagens
⚠️ **ID muda:** Cada OAuth gera um novo credential ID
⚠️ **Atualização manual:** Requer atualizar `N8N_GOOGLE_CREDENTIAL_ID` após cada OAuth (opcional)
⚠️ **Credenciais órfãs:** Credenciais antigas não são automaticamente limpas (mas são sobrescritas)
---
## Implementação
### Função Principal: `upsertGoogleCredential()`
**Arquivo:** `lib/n8n-api.ts`
```typescript
export async function upsertGoogleCredential(
credentialName: string, // "refugio"
clientId: string, // Google Client ID
clientSecret: string, // Google Client Secret
scopes: string, // Escopos OAuth
accessToken?: string, // Access token (opcional)
refreshToken?: string, // Refresh token (opcional)
expiresIn?: number, // Expiração em segundos (opcional)
) {
// Passo 1: Deletar credencial antiga (se existir)
const existingCredentialId = process.env.N8N_GOOGLE_CREDENTIAL_ID;
if (existingCredentialId) {
await deleteCredential(existingCredentialId);
}
// Passo 2: Criar nova credencial
const result = await fetch(`${apiBaseUrl}/credentials`, {
method: "POST",
headers: {
"X-N8N-API-KEY": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: credentialName,
type: "googleCalendarOAuth2Api",
data: {
clientId,
clientSecret,
sendAdditionalBodyProperties: false,
additionalBodyProperties: {},
allowedHttpRequestDomains: "none",
oauthTokenData: accessToken ? {
access_token: accessToken,
refresh_token: refreshToken,
scope: scopes,
token_type: "Bearer",
expiry_date: Date.now() + (expiresIn || 3599) * 1000,
} : undefined,
},
}),
});
// Passo 3: Retornar novo ID
const credential = await result.json();
console.log("IMPORTANTE: Atualize N8N_GOOGLE_CREDENTIAL_ID para:", credential.id);
return credential;
}
```
### Função Auxiliar: `deleteCredential()`
```typescript
export async function deleteCredential(credentialId: string): Promise<boolean> {
try {
const response = await fetch(`${apiBaseUrl}/credentials/${credentialId}`, {
method: "DELETE",
headers: {
"X-N8N-API-KEY": apiKey,
},
});
if (response.ok) {
console.log("[n8n-api] Credential deleted:", credentialId);
return true;
}
console.warn("[n8n-api] Failed to delete credential:", response.statusText);
return false;
} catch (error) {
console.error("[n8n-api] Error deleting credential:", error);
return false;
}
}
```
---
## Uso no Fluxo OAuth
### Callback Handler
**Arquivo:** `app/api/google-calendar/callback/route.ts`
```typescript
// Após obter tokens do Google OAuth
const tokens = await tokenResponse.json();
// Chamar manage-credential para criar/atualizar credencial no n8n
const manageResponse = await fetch("/api/google-calendar/manage-credential", {
method: "POST",
body: JSON.stringify({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in || 3599,
}),
});
const manageResult = await manageResponse.json();
console.log("Credencial criada no n8n:", manageResult.credentialId);
```
### Manage Credential API
**Arquivo:** `app/api/google-calendar/manage-credential/route.ts`
```typescript
export async function POST(request: NextRequest) {
const { accessToken, refreshToken, expiresIn } = await request.json();
// Upsert credencial no n8n (DELETE + POST)
const result = await upsertGoogleCredential(
"refugio",
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
process.env.GOOGLE_CLIENT_SECRET!,
"https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/calendar.events",
accessToken,
refreshToken,
expiresIn,
);
// Salvar credential ID no Supabase
await supabase
.from("integrations")
.upsert({
user_id: user.id,
provider: "google_calendar",
status: "connected",
n8n_credential_id: result.id,
});
return NextResponse.json({ success: true, credentialId: result.id });
}
```
---
## Variáveis de Ambiente
### `.env.local`
```bash
# n8n API Configuration
N8N_API_URL=https://n8n.automatizase.com.br/api/v1
N8N_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Google OAuth Configuration
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret
# n8n Credential ID (opcional - será atualizado automaticamente após OAuth)
N8N_GOOGLE_CREDENTIAL_ID=
```
---
## Fluxo Completo
```
1. Usuário clica "Conectar Google Calendar" no dashboard
2. Portal redireciona para Google OAuth
3. Usuário autoriza no Google
4. Google redireciona para /api/google-calendar/callback
5. Callback troca code por tokens (access_token + refresh_token)
6. Callback chama /api/google-calendar/manage-credential
7. manage-credential chama upsertGoogleCredential()
├─> DELETE credencial antiga (se N8N_GOOGLE_CREDENTIAL_ID existe)
├─> POST cria nova credencial "refugio" com tokens
└─> Retorna novo credential ID
8. manage-credential salva credential ID no Supabase
9. Dashboard redireciona para /dashboard?oauth_success=true
10. ✅ Credencial "refugio" está pronta no n8n com tokens válidos
```
---
## Testes Unitários
**Arquivo:** `lib/__tests__/n8n-api.test.ts`
### Cenários Cobertos
```typescript
✅ deleteCredential - deve deletar credencial com sucesso
✅ deleteCredential - deve retornar false quando credencial não existe
✅ upsertGoogleCredential - deve deletar credencial existente e criar nova (DELETE + POST)
✅ upsertGoogleCredential - deve criar credencial via POST quando não existe N8N_GOOGLE_CREDENTIAL_ID
✅ upsertGoogleCredential - deve lançar erro quando POST falha após DELETE
✅ upsertGoogleCredential - deve continuar tentando criar mesmo se DELETE falhar
```
### Executar Testes
```bash
npm test -- lib/__tests__/n8n-api.test.ts
```
---
## Troubleshooting
### Erro: "Failed to create credential"
**Causa:** Faltam variáveis de ambiente ou payload inválido
**Solução:**
1. Verifique se `N8N_API_URL` e `N8N_API_KEY` estão configurados
2. Verifique se `NEXT_PUBLIC_GOOGLE_CLIENT_ID` e `GOOGLE_CLIENT_SECRET` estão configurados
3. Execute: `bash tmp/test-n8n-schema.sh` para validar API
### Credenciais antigas não deletadas
**Causa:** `N8N_GOOGLE_CREDENTIAL_ID` não está atualizado
**Solução:**
1. Após OAuth bem-sucedido, verifique os logs do servidor
2. Copie o novo credential ID mostrado nos logs
3. Atualize `N8N_GOOGLE_CREDENTIAL_ID` no `.env.local`
4. Reinicie o servidor
### Credential ID muda após cada OAuth
**Comportamento esperado:** A estratégia DELETE + POST sempre cria um novo ID.
**Alternativas:**
- Armazenar credential ID no Supabase (já implementado)
- Usar credential ID do Supabase em vez de `.env.local` (melhoria futura)
---
## Melhorias Futuras
### 1. Buscar Credential ID do Supabase
Em vez de usar `process.env.N8N_GOOGLE_CREDENTIAL_ID`, buscar do banco:
```typescript
const { data } = await supabase
.from("integrations")
.select("n8n_credential_id")
.eq("user_id", user.id)
.eq("provider", "google_calendar")
.single();
const existingCredentialId = data?.n8n_credential_id;
```
### 2. Limpeza Automática de Credenciais Órfãs
Adicionar job periódico para deletar credenciais não usadas.
### 3. Cache de Credential ID
Armazenar credential ID em memória durante sessão para reduzir queries ao Supabase.
---
## Referências
- [n8n API Documentation](https://docs.n8n.io/api/)
- [n8n Community - Get/Update credentials via API](https://community.n8n.io/t/get-update-credentials-via-api/46437)
- [Story 3.4 - Gerenciar Credenciais n8n](../docs/stories/story-3-4-gerenciar-credenciais-n8n.md)
- [Google OAuth Setup](../GOOGLE_OAUTH_SETUP.md)
---
## Changelog
| Data | Versão | Alteração |
|------|--------|-----------|
| 2025-10-12 | 1.0.0 | Implementação inicial da estratégia DELETE + POST |

View File

@ -9,4 +9,7 @@ Integrar com EvolutionAPI para exibir instâncias como cards, visualizar status
**Epic 3: Google Calendar OAuth Integration**
Implementar botão de autorização OAuth que redireciona para n8n, exibir status de conexão do Google Calendar, e permitir re-autenticação.
**Epic 4: DevOps & Deployment**
Containerizar aplicação e preparar infraestrutura completa de deploy em Kubernetes com Docker, manifests K8s (Deployment, Service, Ingress, Secret), CI/CD pipeline, e monitoramento.
---

View File

@ -0,0 +1,128 @@
# Epic 4: DevOps & Deployment
## Epic Goal
Containerizar a aplicação e preparar infraestrutura completa de deploy em Kubernetes, permitindo hospedar o portal AutomatizaSE em produção de forma escalável, segura e gerenciável. Epic entrega imagem Docker otimizada e manifests K8s prontos para deploy.
## Epic Status
- **Status:** Not Started
- **Priority:** P1 (High)
- **Effort Estimate:** 1-2 days
- **Dependencies:** Epic 1, Epic 2, Epic 3 (POC finalizada)
## Value Proposition
Este epic estabelece a infraestrutura de produção, entregando:
- ✅ Aplicação containerizada com Docker otimizado para Next.js
- ✅ Manifests Kubernetes prontos para deploy
- ✅ Configuração de secrets e variáveis de ambiente
- ✅ Ingress configurado com nginx para domínio portal.automatizase.com.br
- ✅ Aplicação rodando em produção no cluster K8s
## User Stories
### Story 4.1: Criar Dockerfile e Manifests Kubernetes para Deploy
[Link: story-4-1-criar-dockerfile-k8s-manifests.md](../../stories/4.1.story.md)
**Descrição:** Criar Dockerfile otimizado para Next.js e todos os manifests Kubernetes necessários (Deployment, Service, Ingress, Secret) para deploy da aplicação no cluster K8s no namespace `automatizase`.
**Escopo:**
- Dockerfile multi-stage build otimizado para produção
- Kubernetes Deployment com envFrom para secrets
- Kubernetes Service para expor aplicação internamente
- Kubernetes Secret com todas as variáveis do `.env.local`
- Kubernetes Ingress com nginx para domínio `portal.automatizase.com.br`
- Porta customizada (não 3000/8080) para evitar conflitos
**Acceptance Criteria:**
1. Dockerfile constrói imagem Next.js com sucesso e otimizada para produção
2. Deployment manifest configurado com:
- Namespace: `automatizase`
- Replicas: 2 (alta disponibilidade)
- Resources limits/requests definidos
- envFrom carregando secrets
- Porta customizada (ex: 3100)
3. Service manifest expõe Deployment internamente
4. Secret manifest contém todas as variáveis do `.env.local` (Supabase, n8n, EvolutionAPI, etc)
5. Ingress manifest configurado com:
- nginx ingress class
- Host: `portal.automatizase.com.br`
- TLS/SSL configurado
- Routing para Service correto
6. Aplicação acessível via `https://portal.automatizase.com.br` após deploy
7. Documentação de deploy criada (README-DEPLOY.md)
### Story 4.2: Criar CI/CD Pipeline para Build e Deploy Automático
[Link: story-4-2-criar-cicd-pipeline.md](../../stories/4.2.story.md)
**Descrição:** Configurar pipeline CI/CD (GitHub Actions ou GitLab CI) para build automático da imagem Docker e deploy no cluster K8s.
**Escopo:**
- Pipeline de build de imagem Docker
- Push para registry (Docker Hub, GCR, ou registry privado)
- Deploy automático no cluster K8s
- Validações e health checks
**Acceptance Criteria:**
1. Pipeline roda automaticamente em push para branch `main`
2. Build de imagem Docker com versionamento (tags)
3. Push de imagem para registry configurado
4. Deploy automático no namespace `automatizase`
5. Health checks validam deploy bem-sucedido
6. Rollback automático em caso de falha
7. Notificações de sucesso/falha do pipeline
### Story 4.3: Configurar Monitoramento e Logs
[Link: story-4-3-configurar-monitoring-logs.md](../../stories/4.3.story.md)
**Descrição:** Configurar coleta de logs e métricas básicas da aplicação rodando no K8s.
**Escopo:**
- Configuração de logs centralizados
- Métricas básicas (CPU, memória, requests)
- Dashboards de monitoramento
- Alertas críticos
**Acceptance Criteria:**
1. Logs da aplicação coletados centralmente (ELK, Loki, CloudWatch, etc)
2. Métricas de pods disponíveis (CPU, memória, network)
3. Dashboard básico criado para visualizar saúde da aplicação
4. Alertas configurados para:
- Pods crashando
- Alto uso de recursos
- Falhas de health checks
5. Documentação de acesso aos logs e métricas
## Technical Notes
- **Dockerfile:** Multi-stage build (build → production)
- **Base Image:** node:20-alpine para produção
- **Registry:** Definir qual registry usar (Docker Hub, GCR, ECR, registry privado)
- **K8s Version:** Compatível com versão atual do cluster
- **Ingress Controller:** nginx-ingress já instalado no cluster
- **Namespace:** `automatizase` (criar se não existir)
- **Porta interna:** 3100 (evitar conflito com 3000/8080)
- **Secrets:** Armazenar credenciais sensíveis (Supabase, n8n, EvolutionAPI)
- **Resources:** Definir limits/requests apropriados (ex: 512Mi RAM, 500m CPU)
## Acceptance Criteria (Epic Level)
1. ✅ Imagem Docker construída e disponível em registry
2. ✅ Aplicação deployada no namespace `automatizase`
3. ✅ Aplicação acessível via `https://portal.automatizase.com.br`
4. ✅ Secrets carregados corretamente (aplicação funciona com credenciais)
5. ✅ Ingress roteia tráfego corretamente para pods
6. ✅ Pods reiniciam automaticamente em caso de falha
7. ✅ Logs e métricas visíveis e coletados
8. ✅ Documentação de deploy completa
## Definition of Done
- [ ] Todas as 3 stories completadas
- [ ] Aplicação rodando em produção no K8s
- [ ] Testes de carga básicos executados
- [ ] Documentação de deploy e troubleshooting criada
- [ ] Rollback testado e funcional
- [ ] Secrets rotacionados e seguros
- [ ] Monitoramento e alertas configurados

View File

@ -1,82 +1,232 @@
# Quality Gate Decision
# Story: 1.4 - Implementar Recuperação de Senha Completa
# Quality Gate Decision - Story 1.4
# Generated by Quinn (Test Architect)
# Review Date: 2025-10-12 (Updated)
# Required fields
schema: 1
story: "1.4"
story_title: "Implementar Recuperação de Senha Completa"
gate: CONCERNS
status_reason: "Implementação funcional e bem estruturada, mas faltam testes automatizados para validar o fluxo crítico de recuperação de senha."
status_reason: "Implementação funcional com todos ACs atendidos, mas faltam testes automatizados para fluxo crítico de autenticação. Nova vulnerabilidade no callback handler identificada."
reviewer: "Quinn (Test Architect)"
updated: "2025-10-05T00:00:00Z"
updated: "2025-10-12T00:00:00Z"
waiver: { active: false }
# Waiver status
waiver:
active: false
# Top issues identified
top_issues:
- id: "TEST-001"
severity: high
finding: "Ausência de testes automatizados para fluxo de recuperação de senha"
suggested_action: "Implementar testes usando Vitest + React Testing Library para componentes e Playwright para fluxo E2E"
suggested_owner: dev
finding: "Ausência total de testes automatizados para fluxo crítico de recuperação de senha"
suggested_action: "Implementar pelo menos 1 teste E2E para fluxo completo (happy path) antes de produção"
suggested_owner: "dev"
refs:
- "app/reset-password/page.tsx"
- "app/update-password/page.tsx"
- "app/auth/callback/route.ts"
- id: "SEC-001"
severity: medium
finding: "Rate limiting não implementado nos endpoints de autenticação"
suggested_action: "Adicionar rate limiting via middleware ou Supabase Edge Functions para prevenir ataques de força bruta"
suggested_owner: dev
finding: "Rate limiting não implementado em endpoints de autenticação"
suggested_action: "Adicionar rate limiting via middleware ou Supabase Edge Functions antes de produção"
suggested_owner: "dev"
refs:
- "app/reset-password/page.tsx:33-39"
- id: "SEC-002"
severity: medium
finding: "Callback handler não valida erros de exchangeCodeForSession, permitindo redirecionamento sem sessão válida"
suggested_action: "Adicionar validação de erro e redirecionar para /login com mensagem de erro apropriada"
suggested_owner: "dev"
refs:
- "app/auth/callback/route.ts:29"
- id: "ARCH-001"
severity: low
finding: "Cliente Supabase instanciado diretamente nas páginas client-side"
suggested_action: "Considerar criar service layer (auth.service.ts) para centralizar lógica de autenticação e facilitar testing"
suggested_owner: dev
finding: "Cliente Supabase instanciado diretamente nos componentes, dificultando testing e reutilização"
suggested_action: "Considerar criar services/auth.service.ts para centralizar lógica de autenticação"
suggested_owner: "dev"
refs:
- "app/reset-password/page.tsx:5"
- "app/update-password/page.tsx:5"
- id: "SEC-003"
severity: low
finding: "Validação de senha permite senhas fracas (apenas comprimento mínimo de 6 caracteres)"
suggested_action: "Adicionar validação de complexidade (maiúsculas, números, símbolos) em futuro próximo"
suggested_owner: "dev"
refs:
- "app/update-password/page.tsx:26-30"
# Risk summary
risk_summary:
totals: { critical: 0, high: 1, medium: 1, low: 1 }
totals:
critical: 0
high: 1 # TEST-001: Ausência de testes
medium: 2 # SEC-001: Rate limiting, SEC-002: Callback error handling
low: 2 # ARCH-001: Service layer, SEC-003: Password complexity
highest: high
recommendations:
must_fix:
- "Implementar testes automatizados para fluxo crítico de recuperação de senha"
- "Implementar teste E2E para fluxo completo de recuperação de senha (3-4h)"
- "Corrigir error handling no callback handler (30min)"
monitor:
- "Adicionar rate limiting para endpoints de autenticação"
- "Considerar refatoração para service layer pattern"
- "Rate limiting antes de deploy em produção (2-3h)"
- "Validação de complexidade de senha (1-2h futuro)"
# Quality score calculation
# 100 - (20 × 0 critical) - (10 × 1 high) - (5 × 2 medium) - (2 × 2 low) = 76
# Ajustado para 68 devido ao impacto crítico da ausência de testes em autenticação
quality_score: 68
expires: "2025-10-26T00:00:00Z" # 2 semanas após revisão
# Evidence collected
evidence:
tests_reviewed: 0
risks_identified: 3
tests_reviewed: 0 # Nenhum teste encontrado para esta feature
risks_identified: 5
trace:
ac_covered: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
ac_gaps: []
ac_covered: [] # Nenhum AC tem cobertura de teste
ac_gaps: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] # Todos ACs sem testes
# NFR validation
nfr_validation:
security:
status: CONCERNS
notes: "Fluxo de recuperação implementado corretamente usando Supabase Auth. Credenciais protegidas via .gitignore. Faltam rate limiting e testes de segurança."
notes: |
- Falta rate limiting (ataques de força bruta possíveis)
- Callback handler não valida erros (má UX)
- Validação de senha fraca (permite senhas fracas)
- Nenhum teste de segurança implementado
performance:
status: PASS
notes: "Implementação client-side com validações síncronas. Sem operações bloqueantes identificadas."
notes: |
- Validações síncronas executadas antes de chamadas API
- Loading states implementados corretamente
- Sem issues de performance identificadas
reliability:
status: CONCERNS
notes: "Tratamento de erros implementado. Faltam testes automatizados para validar cenários de falha e recuperação."
notes: |
- Ausência de testes automatizados representa alto risco de regressão
- Error handling no callback pode causar má experiência do usuário
- Sem testes de confiabilidade para validar comportamento em cenários de erro
maintainability:
status: PASS
notes: "Código limpo, bem estruturado e aderente aos padrões de nomenclatura. Comentários adequados. CSS centralizado com variáveis."
quality_score: 70
expires: "2025-10-19T00:00:00Z"
notes: |
- Código limpo e bem estruturado
- Nomenclatura consistente com padrões do projeto
- Type safety com TypeScript
- Separação de responsabilidades adequada
# Recommendations
recommendations:
immediate:
- action: "Implementar testes unitários para componentes de autenticação"
refs:
- "app/reset-password/page.tsx"
- "app/update-password/page.tsx"
- action: "Implementar teste E2E para fluxo completo de recuperação de senha"
refs:
- "Criar: tests/e2e/password-recovery.spec.ts"
future:
- action: "Adicionar rate limiting em endpoints de autenticação"
refs:
- "app/auth/callback/route.ts"
- action: "Criar service layer para autenticação"
refs:
- "Criar: services/auth.service.ts"
- action: "Adicionar validação de força de senha"
refs:
- "app/update-password/page.tsx"
immediate: # Blocker para produção
- action: "Implementar pelo menos 1 teste E2E para fluxo completo: reset → email → callback → update → login"
refs: ["Todas as páginas do fluxo de recuperação"]
estimated_effort: "3-4 horas"
- action: "Adicionar validação de erro no callback handler com redirecionamento adequado"
refs: ["app/auth/callback/route.ts:29"]
estimated_effort: "30 minutos"
code_suggestion: |
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return NextResponse.redirect(
`${requestUrl.origin}/login?error=invalid_recovery_link`
);
}
high_priority: # Antes de deploy em produção
- action: "Implementar rate limiting em endpoints de autenticação"
refs: ["app/reset-password/page.tsx", "app/update-password/page.tsx"]
estimated_effort: "2-3 horas"
- action: "Criar testes de integração para callback handler"
refs: ["app/auth/callback/route.ts"]
estimated_effort: "2-3 horas"
future: # Melhorias não-bloqueantes
- action: "Criar services/auth.service.ts para centralizar lógica de autenticação"
refs: ["lib/supabase.ts", "Componentes de auth"]
estimated_effort: "2-3 horas"
- action: "Adicionar validação de complexidade de senha"
refs: ["app/update-password/page.tsx:26-30"]
estimated_effort: "1-2 horas"
- action: "Implementar testes unitários para validações"
refs: ["app/reset-password/page.tsx", "app/update-password/page.tsx"]
estimated_effort: "2-4 horas"
# History (append-only audit trail)
history:
- at: "2025-10-05T00:00:00Z"
gate: CONCERNS
reviewer: "Quinn (Test Architect)"
quality_score: 70
note: "Primeira revisão - implementação funcional mas faltam testes automatizados e rate limiting"
- at: "2025-10-12T00:00:00Z"
gate: CONCERNS
reviewer: "Quinn (Test Architect)"
quality_score: 68
note: "Segunda revisão - nenhum progresso em testes. Nova vulnerabilidade identificada no callback handler. Score reduzido de 70 para 68."
# Technical debt summary
technical_debt:
total_estimated_effort: "13.5-20.5 horas"
critical_items: 2 # Testes E2E + Callback error handling
items:
- id: "TD-001"
type: "Testing"
severity: "Alta"
description: "Ausência total de testes automatizados"
estimated_effort: "8-12 horas"
status: "Inalterado desde 2025-10-05"
- id: "TD-002"
type: "Security"
severity: "Média"
description: "Rate limiting não implementado"
estimated_effort: "2-3 horas"
status: "Inalterado desde 2025-10-05"
- id: "TD-003"
type: "Security"
severity: "Média"
description: "Callback não valida erro de exchangeCodeForSession"
estimated_effort: "30 minutos"
status: "Nova em 2025-10-12"
- id: "TD-004"
type: "Architecture"
severity: "Baixa"
description: "Cliente Supabase acoplado aos componentes"
estimated_effort: "2-3 horas"
status: "Inalterado desde 2025-10-05"
- id: "TD-005"
type: "Security"
severity: "Baixa"
description: "Validação de senha fraca"
estimated_effort: "1-2 horas"
status: "Inalterado desde 2025-10-05"
# Next steps
next_steps:
for_dev:
- "Decidir se implementa testes agora ou solicita WAIVER do PO"
- "Corrigir callback handler error handling (30min - quick win)"
- "Se continuar sem testes, documentar plano de testes manuais e monitoramento"
for_po:
- "Avaliar se aceita risco de deploy sem testes (requer WAIVER explícito)"
- "Considerar criar stories técnicas separadas (1.4.1 para testes, 1.4.2 para segurança)"
for_qa:
- "Se WAIVER for aprovado, preparar script de testes manuais rigorosos"
- "Monitorar logs do Supabase intensivamente após deploy"

View File

@ -0,0 +1,16 @@
-- Migration: Adicionar coluna n8n_credential_name na tabela integrations
-- Story: 3.4 - Gerenciar Credenciais Google OAuth via API do n8n
-- Data: 2025-10-12
-- Objetivo: Armazenar o nome da credencial (sempre "refugio") para controle e validação
-- Adicionar coluna para armazenar nome da credencial no n8n
ALTER TABLE portal.integrations
ADD COLUMN IF NOT EXISTS n8n_credential_name VARCHAR(100) DEFAULT 'refugio';
-- Comentário para documentação
COMMENT ON COLUMN portal.integrations.n8n_credential_name IS 'Nome da credencial Google OAuth2 no n8n (sempre "refugio" para não quebrar workflows)';
-- Atualizar registros existentes (se houver) para garantir que tenham o nome "refugio"
UPDATE portal.integrations
SET n8n_credential_name = 'refugio'
WHERE provider = 'google_calendar' AND n8n_credential_name IS NULL;

View File

@ -706,3 +706,286 @@ Embora a implementação esteja funcional e todos os ACs sejam atendidos, **aute
- Criar story técnica separada para testes (não recomendado)
**Decisão Final:** Story owner decide. Esta é uma recomendação consultiva do Test Architect.
---
### Review Date: 2025-10-12
### Reviewed By: Quinn (Test Architect)
### Segunda Revisão - Status Inalterado
**Resumo Executivo:** A implementação revisada em 12/10/2025 permanece tecnicamente sólida e funcional. Todos os 14 critérios de aceitação continuam atendidos. O código segue os padrões do projeto (coding standards, tech stack). **No entanto, a preocupação crítica identificada na primeira revisão permanece: ausência total de testes automatizados para fluxo crítico de autenticação.**
**Status em relação à revisão anterior (2025-10-05):**
- ✅ Código permanece limpo e bem estruturado
- ✅ Validações client-side intactas e corretas
- ✅ Tratamento de erros consistente
- ❌ **CRÍTICO:** Nenhum teste automatizado adicionado desde última revisão
- ❌ **ALTA:** Rate limiting continua ausente
- ⚠️ **Nova Observação:** Callback handler não valida se `code` é válido antes de processar
### Code Quality Assessment
**Pontos Fortes Mantidos:**
- ✅ Separação de responsabilidades clara
- ✅ Type safety com TypeScript
- ✅ Validações robustas em `reset-password` e `update-password`
- ✅ Error boundaries implementados (try/catch)
- ✅ Loading states para UX adequada
- ✅ Tema escuro consolidado e responsivo
**Novas Preocupações Identificadas:**
1. **Callback Handler Vulnerability (app/auth/callback/route.ts:9-29)**
- **Issue:** Se `code` for string vazia ou malformada, `exchangeCodeForSession` pode falhar silenciosamente
- **Impacto:** Usuário é redirecionado para `/update-password` sem sessão válida
- **Recomendação:** Adicionar validação de erro e redirecionar para `/login` com mensagem de erro
- **Severidade:** MÉDIA
2. **Environment Variables Exposure (lib/supabase.ts:4-5, app/auth/callback/route.ts:13-14)**
- **Issue:** Em `callback/route.ts` usa fallback `|| ""` para env vars
- **Inconsistência:** `lib/supabase.ts` lança erro se env vars ausentes, mas callback aceita strings vazias
- **Recomendação:** Uniformizar validação - callback também deve falhar fast se env vars ausentes
- **Severidade:** BAIXA (ambiente dev)
### Refactoring Performed
Nenhum refactoring foi realizado nesta revisão. As issues identificadas exigem discussão com o time antes de modificações.
### Compliance Check
- **Coding Standards:** ✓ Aderente
- Nomenclatura: Componentes em PascalCase, funções em camelCase
- API Routes: kebab-case correto (`/auth/callback`)
- Error handling: Try/catch em operações assíncronas
- **Tech Stack:** ✓ Aderente
- Next.js 14+ App Router: ✓
- TypeScript 5.3+: ✓
- Supabase Auth: ✓
- TailwindCSS: ✓
- **Testing Strategy:** ✗ NÃO ADERENTE
- Tech stack exige: Vitest + React Testing Library + Playwright
- **Implementado:** NENHUM teste
- **Gap:** 100% da funcionalidade sem cobertura de testes
- **All ACs Met:** ✓ Sim (14/14 critérios implementados)
### Requirements Traceability - Atualizado
**AC 1-6: Fluxo de Solicitação de Recuperação**
**Given** usuário esqueceu sua senha e acessa `/reset-password`
**When** insere email válido e clica "Enviar email de recuperação"
**Then**
- Email é enviado via Supabase `resetPasswordForEmail`
- Mensagem de sucesso exibida: "Email enviado! Verifique sua caixa de entrada"
- Link "Voltar para login" funciona
**Cobertura de Testes:** ❌ AUSENTE
- **Missing:** Unit test para validação de email (regex)
- **Missing:** Unit test para mensagens de erro/sucesso
- **Missing:** E2E test para fluxo completo
- **Risk Score:** 7/10 (Alta probabilidade × Médio impacto)
**AC 7-9: Callback Handler**
**Given** usuário clica link no email de recuperação
**When** Supabase redireciona para `/auth/callback?code=xyz&type=recovery`
**Then**
- Código é trocado por sessão via `exchangeCodeForSession`
- Usuário é redirecionado para `/update-password`
**Cobertura de Testes:** ❌ AUSENTE
- **Missing:** Integration test para callback handler
- **Missing:** Test para cenário de código inválido/expirado
- **Missing:** Test para cenário sem código
- **Risk Score:** 8/10 (Alta probabilidade × Alto impacto) - **NOVO: aumentado de 7 para 8**
**AC 10-11: Atualização de Senha**
**Given** usuário está em `/update-password` com sessão ativa
**When** insere nova senha (≥6 caracteres) e confirmação coincidente
**Then**
- Senha é atualizada via `supabase.auth.updateUser`
- Usuário é redirecionado para `/login?message=password-updated`
**Cobertura de Testes:** ❌ AUSENTE
- **Missing:** Unit test para validações (comprimento, match)
- **Missing:** Integration test para `updateUser`
- **Missing:** E2E test para fluxo de sucesso
- **Risk Score:** 7/10 (Alta probabilidade × Médio impacto)
**AC 12-14: Tema e Responsividade**
**Given** qualquer página de auth é acessada
**When** visualizada em dispositivos desktop/mobile/tablet
**Then** tema escuro aplicado e layout responsivo
**Cobertura de Testes:** ❌ AUSENTE
- **Missing:** Visual regression tests (Playwright screenshots)
- **Risk Score:** 3/10 (Baixa probabilidade × Baixo impacto) - Visual QA pode substituir
### Security Review - Atualizado
**✓ Implementado Corretamente:**
- Uso de `resetPasswordForEmail` (não revela se email existe)
- Credenciais em `.env.local` protegidas por `.gitignore`
- Links com expiração (1 hora padrão Supabase)
- Validação de sessão OAuth via callback
**⚠️ Preocupações Mantidas:**
1. **Rate Limiting (ALTA - INALTERADO)**
- **Status:** Não implementado
- **Risco:** Ataques de força bruta, email flooding
- **Recomendação:** Adicionar rate limiting via middleware ou Edge Functions
- **Action Required:** Antes de produção
2. **Validação de Senha Fraca (MÉDIA - INALTERADO)**
- **Status:** Apenas comprimento mínimo (6 caracteres)
- **Risco:** Senhas fracas ("123456", "qwerty")
- **Recomendação:** Adicionar validação de complexidade
- **Action Required:** Futuro
**🆕 Novas Preocupações:**
3. **Callback Error Handling (MÉDIA - NOVA)**
- **Status:** Não valida se `exchangeCodeForSession` falhou
- **Risco:** Usuário redirecionado para `/update-password` sem sessão
- **Impacto:** Página de update password falhará silenciosamente
- **Recomendação:**
```typescript
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return NextResponse.redirect(
`${requestUrl.origin}/login?error=invalid_recovery_link`
);
}
```
- **Action Required:** Antes de produção
### Performance Considerations
**✓ Boas Práticas Mantidas:**
- Componentes client-side otimizados (sem re-renders desnecessários)
- Validações síncronas antes de chamadas API
- Loading states para feedback visual
**Nenhuma Issue de Performance:** Adequado para POC e produção.
### Test Architecture Assessment - Atualizado
**Status Atual:** ❌ **CRÍTICO - SEM MUDANÇAS DESDE ÚLTIMA REVISÃO**
**Test Debt Atual (mesmos gaps de 2025-10-05):**
| Test Suite | Priority | Status | Estimated Effort |
|------------|----------|--------|------------------|
| Unit: ResetPasswordPage | ALTA | ❌ Ausente | 1-2 horas |
| Unit: UpdatePasswordPage | ALTA | ❌ Ausente | 1-2 horas |
| Integration: Callback Handler | ALTA | ❌ Ausente | 2-3 horas |
| E2E: Fluxo Completo | **CRÍTICA** | ❌ Ausente | 3-4 horas |
| Visual: Tema Escuro | BAIXA | ❌ Ausente | 1 hora |
**Total Test Debt:** 8-12 horas (inalterado)
**Recomendação de Priorização:**
1. **Fase 1 (Mínimo Viável):** E2E test para happy path (3-4h)
2. **Fase 2 (Crítico):** Integration test para callback handler (2-3h)
3. **Fase 3 (Importante):** Unit tests para validações (2-4h)
### Technical Debt Identification - Atualizado
| ID | Tipo | Severidade | Descrição | Esforço | Status desde 2025-10-05 |
|----|------|------------|-----------|---------|-------------------------|
| TD-001 | Testing | **Alta** | Ausência total de testes automatizados | 8-12h | ❌ INALTERADO |
| TD-002 | Security | Média | Rate limiting não implementado | 2-3h | ❌ INALTERADO |
| TD-003 | Security | **Média** | Callback não valida erro de `exchangeCodeForSession` | 30min | 🆕 NOVA |
| TD-004 | Architecture | Baixa | Cliente Supabase acoplado aos componentes | 2-3h | ❌ INALTERADO |
| TD-005 | Security | Baixa | Validação de senha fraca | 1-2h | ❌ INALTERADO |
**Total Technical Debt:** 13.5-20.5 horas
### Improvements Checklist - Consolidado
**Segurança (Crítico para Produção):**
- [ ] Implementar rate limiting em `/reset-password` e `/update-password` (TD-002)
- [ ] Adicionar validação de erro em callback handler (TD-003) - **NOVA**
- [ ] Implementar logging de tentativas de recuperação para auditoria
- [ ] Adicionar validação de complexidade de senha (TD-005)
**Testes (Blocker para Produção):**
- [ ] Criar teste E2E para fluxo completo: reset → email → callback → update → login (TD-001)
- [ ] Criar teste de integração para callback handler com mock Supabase (TD-001)
- [ ] Criar testes unitários para `ResetPasswordPage` (validações) (TD-001)
- [ ] Criar testes unitários para `UpdatePasswordPage` (validações) (TD-001)
**Arquitetura (Futuro):**
- [ ] Criar `services/auth.service.ts` para desacoplar lógica de auth (TD-004)
- [ ] Extrair validações para `lib/validators/auth.validator.ts`
- [ ] Implementar custom hook `usePasswordReset` para reutilização
**UX (Nice-to-Have):**
- [ ] Adicionar indicador visual de força de senha
- [ ] Implementar toast notifications (ex: Sonner)
- [ ] Adicionar timer de reenvio de email (cooldown de 60s)
### Files Modified During Review
Nenhum arquivo foi modificado nesta revisão. As issues identificadas requerem decisão do time.
### Gate Status
**Gate:** CONCERNS → `docs/qa/gates/1.4-implementar-recuperacao-senha.yml`
**Quality Score:** 68/100 (**reduzido de 70** devido à nova issue de callback)
**Razão:** Implementação funcional continua sem testes automatizados. Nova vulnerabilidade identificada no callback handler aumenta risco de segurança para MÉDIA-ALTA.
**Issues por Severidade (Atualizado):**
- Alta: 1 (testes ausentes) - INALTERADO
- Média: 2 (rate limiting + callback error handling) - **AUMENTADO de 1 para 2**
- Baixa: 2 (service layer + validação senha) - INALTERADO
**NFR Summary (Atualizado):**
- Security: **CONCERNS** (faltam rate limiting, error handling no callback, testes de segurança)
- Performance: PASS
- Reliability: **CONCERNS** (faltam testes de confiabilidade + error handling no callback)
- Maintainability: PASS
### Recommended Status
**✗ Changes Required - Implementar Testes E Corrigir Callback Handler Antes de Produção**
**Justificativa Atualizada:**
Esta segunda revisão reafirma a **necessidade crítica de testes automatizados** e identifica uma **nova vulnerabilidade no callback handler** que pode resultar em má experiência do usuário (redirecionamento para página sem sessão).
**Mudanças desde 2025-10-05:**
- ✅ Código-fonte permanece estável (nenhum refactoring necessário)
- ❌ Nenhum teste adicionado (technical debt inalterado)
- ⚠️ Nova vulnerabilidade de error handling identificada (aumenta risco)
**Para mover para "Done" - Requisitos Atualizados:**
1. **OBRIGATÓRIO (Segurança):** Corrigir error handling no callback handler (30 min)
2. **OBRIGATÓRIO (Qualidade):** Implementar pelo menos 1 teste E2E para fluxo completo (3-4h)
3. **RECOMENDADO:** Implementar rate limiting antes de deploy em produção (2-3h)
**Alternativa (Aceitação de Risco):**
- Story owner pode solicitar WAIVER do gate com aprovação do Product Owner
- Criar story técnica separada para:
- **Story 1.4.1:** Implementar testes para recuperação de senha (8-12h)
- **Story 1.4.2:** Adicionar rate limiting e melhorias de segurança (3-4h)
**Observação Importante:**
Se esta feature for para produção sem testes, recomendo fortemente:
- Monitoramento intensivo de logs (Supabase Dashboard)
- Testes manuais rigorosos em staging
- Rollback plan documentado
**Decisão Final:** Story owner decide. Esta é uma recomendação consultiva do Test Architect baseada em análise de risco.

236
k8s/README-DEPLOY.md Normal file
View File

@ -0,0 +1,236 @@
# Deploy do Dashboard AutomatizaSE no Kubernetes
## 📋 Pré-requisitos
1. Cluster Kubernetes configurado
2. `kubectl` instalado e configurado
3. Namespace `automatizase` criado
4. Acesso ao registry `git.automatizase.com.br`
5. Credenciais do registry configuradas
## 🚀 Passo a Passo
### 1. Criar o Namespace (se não existir)
```bash
kubectl create namespace automatizase
```
### 2. Criar Secret do Registry (imagePullSecret)
Este secret permite que o Kubernetes faça pull da imagem do registry privado:
```bash
kubectl create secret docker-registry gitea-registry-secret \
--docker-server=git.automatizase.com.br \
--docker-username=SEU_USUARIO \
--docker-password=SUA_SENHA \
--docker-email=SEU_EMAIL \
-n automatizase
```
**Verificar:**
```bash
kubectl get secret gitea-registry-secret -n automatizase
```
### 3. Criar Secret das Variáveis de Ambiente (OPCIONAL)
⚠️ **NOTA:** Como as variáveis já estão hardcoded na imagem Docker, este passo é **OPCIONAL**.
Se quiser sobrescrever as variáveis em runtime:
```bash
kubectl create secret generic portal-secrets \
--from-literal=NEXT_PUBLIC_SITE_URL=https://portal.automatizase.com.br \
--from-literal=NEXT_PUBLIC_SUPABASE_URL=https://supabase.automatizase.com.br \
--from-literal=NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... \
--from-literal=NEXT_PUBLIC_GOOGLE_CLIENT_ID=174466774807-tdsht53agf7v40suk5mmqgmfrn4iskck.apps.googleusercontent.com \
--from-literal=SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... \
--from-literal=GOOGLE_CLIENT_SECRET=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 \
--from-literal=EVOLUTION_API_URL=https://evolutionapi.automatizase.com.br \
--from-literal=EVOLUTION_API_KEY=03919932dcb10fee6f28b1f1013b304c \
--from-literal=EVOLUTION_INSTANCE_NAMES="Rita,Lucia Refugio" \
--from-literal=N8N_API_URL=https://n8n.automatizase.com.br/api/v1 \
--from-literal=N8N_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... \
--from-literal=N8N_OAUTH_URL=https://n8n.automatizase.com.br/webhook/google-oauth \
-n automatizase
```
**Verificar:**
```bash
kubectl get secret portal-secrets -n automatizase
```
### 4. Aplicar os Manifestos
Aplique os manifestos na ordem correta:
```bash
# 1. Service (expõe os pods internamente)
kubectl apply -f k8s/service.yaml
# 2. Deployment (cria os pods)
kubectl apply -f k8s/deployment.yaml
# 3. Ingress (expõe externamente via HTTPS)
kubectl apply -f k8s/ingress.yaml
```
**Ou aplicar todos de uma vez:**
```bash
kubectl apply -f k8s/
```
### 5. Verificar o Deploy
```bash
# Verificar pods
kubectl get pods -n automatizase -l app=portal
# Verificar logs
kubectl logs -f -n automatizase -l app=portal
# Verificar service
kubectl get svc -n automatizase portal-service
# Verificar ingress
kubectl get ingress -n automatizase portal-ingress
```
### 6. Testar a Aplicação
```bash
# Verificar health check
kubectl exec -it -n automatizase $(kubectl get pod -n automatizase -l app=portal -o jsonpath='{.items[0].metadata.name}') -- curl http://localhost:3100/api/health
# Ou acesse via browser:
# https://portal.automatizase.com.br
```
## 🔄 Atualizações
### Atualizar a Imagem
1. **Build e push da nova versão:**
```bash
docker build -t git.automatizase.com.br/luis.erlacher/dashboard-automatizase:latest .
docker push git.automatizase.com.br/luis.erlacher/dashboard-automatizase:latest
```
2. **Forçar atualização no Kubernetes:**
```bash
kubectl rollout restart deployment portal -n automatizase
```
3. **Verificar rollout:**
```bash
kubectl rollout status deployment portal -n automatizase
```
### Rollback
```bash
# Ver histórico de rollouts
kubectl rollout history deployment portal -n automatizase
# Fazer rollback para versão anterior
kubectl rollout undo deployment portal -n automatizase
# Rollback para versão específica
kubectl rollout undo deployment portal -n automatizase --to-revision=2
```
## 🐛 Troubleshooting
### Pods não iniciam (ImagePullBackOff)
```bash
# Verificar eventos
kubectl describe pod -n automatizase -l app=portal
# Verificar se o imagePullSecret está correto
kubectl get secret gitea-registry-secret -n automatizase -o yaml
```
**Solução:** Recriar o secret do registry com as credenciais corretas.
### Aplicação não responde
```bash
# Verificar logs
kubectl logs -f -n automatizase -l app=portal
# Verificar probes
kubectl describe pod -n automatizase -l app=portal | grep -A 10 "Liveness\|Readiness"
```
### Ingress não funciona
```bash
# Verificar ingress controller
kubectl get pods -n ingress-nginx
# Verificar certificado TLS
kubectl get secret portal-tls-cert -n automatizase
# Verificar DNS
nslookup portal.automatizase.com.br
```
## 📊 Monitoramento
### Métricas dos Pods
```bash
# CPU e memória dos pods
kubectl top pods -n automatizase -l app=portal
# Descrição detalhada
kubectl describe deployment portal -n automatizase
```
### Logs em Tempo Real
```bash
# Todos os pods
kubectl logs -f -n automatizase -l app=portal
# Pod específico
kubectl logs -f -n automatizase <nome-do-pod>
# Últimas 100 linhas
kubectl logs --tail=100 -n automatizase -l app=portal
```
## 🗑️ Remoção
```bash
# Remover tudo
kubectl delete -f k8s/
# Ou remover individualmente
kubectl delete ingress portal-ingress -n automatizase
kubectl delete service portal-service -n automatizase
kubectl delete deployment portal -n automatizase
kubectl delete secret portal-secrets -n automatizase
kubectl delete secret gitea-registry-secret -n automatizase
```
## 📝 Notas Importantes
1. **Variáveis Hardcoded:** As variáveis de ambiente já estão embutidas na imagem Docker. O secret `portal-secrets` é opcional e só será usado se você quiser sobrescrever valores em runtime.
2. **Google OAuth:** Certifique-se de adicionar `https://portal.automatizase.com.br/api/google-calendar/callback` nas URLs autorizadas no Google Cloud Console.
3. **Certificado TLS:** O manifesto `ingress.yaml` referencia o secret `portal-tls-cert`. Certifique-se de que ele existe ou configure o cert-manager para gerar automaticamente.
4. **Registry Privado:** O secret `gitea-registry-secret` é **obrigatório** para fazer pull da imagem do registry privado.
## 🔗 Recursos
- **Imagem Docker:** `git.automatizase.com.br/luis.erlacher/dashboard-automatizase:latest`
- **URL Produção:** `https://portal.automatizase.com.br`
- **Namespace:** `automatizase`
- **Porta Container:** `3100`
- **Health Check:** `/api/health`

View File

@ -16,9 +16,13 @@ spec:
labels:
app: portal
spec:
# Secret para pull de imagem do registry privado
imagePullSecrets:
- name: gitea-registry-secret
containers:
- name: nextjs
image: registry.automatizase.com/portal:latest
image: git.automatizase.com.br/luis.erlacher/dashboard-automatizase:latest
imagePullPolicy: Always
ports:
- containerPort: 3100

33
k8s/registry-secret.yaml Normal file
View File

@ -0,0 +1,33 @@
# ⚠️ ATENÇÃO: Este é um TEMPLATE para o imagePullSecret!
# NÃO commitar valores reais neste arquivo!
#
# Para criar o secret do registry, use um dos métodos abaixo:
#
# Método 1 - Criar secret diretamente:
# kubectl create secret docker-registry gitea-registry-secret \
# --docker-server=git.automatizase.com.br \
# --docker-username=<seu-usuario> \
# --docker-password=<sua-senha> \
# --docker-email=<seu-email> \
# -n automatizase
#
# Método 2 - Usar arquivo .dockerconfigjson:
# kubectl create secret generic gitea-registry-secret \
# --from-file=.dockerconfigjson=$HOME/.docker/config.json \
# --type=kubernetes.io/dockerconfigjson \
# -n automatizase
#
# Verificar se o secret foi criado:
# kubectl get secret gitea-registry-secret -n automatizase
---
apiVersion: v1
kind: Secret
metadata:
name: gitea-registry-secret
namespace: automatizase
type: kubernetes.io/dockerconfigjson
data:
# Este campo precisa ser preenchido com o base64 do dockerconfigjson
# Use os comandos acima para criar o secret corretamente
.dockerconfigjson: YOUR_BASE64_DOCKER_CONFIG_HERE

View File

@ -7,6 +7,7 @@ process.env.N8N_GOOGLE_CREDENTIAL_ID = "cred-123";
import {
createGoogleCredential,
deleteCredential,
updateCredentialTokens,
upsertGoogleCredential,
} from "../n8n-api";
@ -19,55 +20,51 @@ describe("n8n-api", () => {
vi.clearAllMocks();
});
describe("upsertGoogleCredential", () => {
it("deve atualizar credencial existente via PUT", async () => {
const mockResponse = {
id: "cred-123",
name: "refugio",
type: "googleOAuth2Api",
};
describe("deleteCredential", () => {
it("deve deletar credencial com sucesso", async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const result = await upsertGoogleCredential(
"refugio",
"client-id",
"client-secret",
"https://www.googleapis.com/auth/calendar.events",
"access-token",
"refresh-token",
3599,
);
const result = await deleteCredential("cred-123");
expect(result.id).toBe("cred-123");
expect(result).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/credentials/cred-123"),
expect.objectContaining({
method: "PUT",
method: "DELETE",
headers: expect.objectContaining({
"X-N8N-API-KEY": expect.any(String),
"Content-Type": "application/json",
}),
}),
);
});
it("deve criar nova credencial via POST se PUT falhar", async () => {
const mockResponse = {
id: "new-cred-456",
name: "refugio",
type: "googleOAuth2Api",
};
// PUT falha
it("deve retornar false quando credencial não existe", async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: false,
statusText: "Not Found",
});
const result = await deleteCredential("invalid-id");
expect(result).toBe(false);
});
});
describe("upsertGoogleCredential", () => {
it("deve deletar credencial existente e criar nova (DELETE + POST)", async () => {
const mockResponse = {
id: "new-cred-456",
name: "refugio",
type: "googleCalendarOAuth2Api",
};
// DELETE sucede
(global.fetch as any).mockResolvedValueOnce({
ok: true,
});
// POST sucede
(global.fetch as any).mockResolvedValueOnce({
ok: true,
@ -86,6 +83,52 @@ describe("n8n-api", () => {
expect(result.id).toBe("new-cred-456");
expect(global.fetch).toHaveBeenCalledTimes(2);
// Primeiro DELETE
expect(global.fetch).toHaveBeenNthCalledWith(
1,
expect.stringContaining("/credentials/cred-123"),
expect.objectContaining({ method: "DELETE" }),
);
// Depois POST
expect(global.fetch).toHaveBeenNthCalledWith(
2,
expect.stringContaining("/credentials"),
expect.objectContaining({ method: "POST" }),
);
});
it("deve criar credencial via POST quando não existe N8N_GOOGLE_CREDENTIAL_ID", async () => {
// Temporariamente remove credential ID
const originalId = process.env.N8N_GOOGLE_CREDENTIAL_ID;
delete process.env.N8N_GOOGLE_CREDENTIAL_ID;
const mockResponse = {
id: "new-cred-789",
name: "refugio",
type: "googleCalendarOAuth2Api",
};
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const result = await upsertGoogleCredential(
"refugio",
"client-id",
"client-secret",
"https://www.googleapis.com/auth/calendar.events",
);
expect(result.id).toBe("new-cred-789");
expect(global.fetch).toHaveBeenCalledTimes(1); // Apenas POST
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/credentials"),
expect.objectContaining({ method: "POST" }),
);
// Restaura credential ID
process.env.N8N_GOOGLE_CREDENTIAL_ID = originalId;
});
});
@ -200,16 +243,15 @@ describe("n8n-api", () => {
});
describe("upsertGoogleCredential - error scenarios", () => {
it("deve lançar erro quando POST falha após PUT falhar", async () => {
it("deve lançar erro quando POST falha após DELETE", async () => {
const mockError = { message: "Internal server error" };
// PUT falha
// DELETE sucede
(global.fetch as any).mockResolvedValueOnce({
ok: false,
statusText: "Not Found",
ok: true,
});
// POST também falha
// POST falha
(global.fetch as any).mockResolvedValueOnce({
ok: false,
json: async () => mockError,
@ -227,5 +269,35 @@ describe("n8n-api", () => {
),
).rejects.toThrow("Failed to create credential");
});
it("deve continuar tentando criar mesmo se DELETE falhar", async () => {
const mockResponse = {
id: "new-cred-999",
name: "refugio",
type: "googleCalendarOAuth2Api",
};
// DELETE falha (credencial não existe)
(global.fetch as any).mockResolvedValueOnce({
ok: false,
statusText: "Not Found",
});
// POST sucede
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});
const result = await upsertGoogleCredential(
"refugio",
"client-id",
"client-secret",
"https://www.googleapis.com/auth/calendar.events",
);
expect(result.id).toBe("new-cred-999");
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
});

View File

@ -44,20 +44,56 @@ export async function getCredentialSchema(type: string) {
return response.json();
}
/**
* Deletar credencial existente no n8n
*
* @param credentialId - ID da credencial a ser deletada
* @returns true se deletado com sucesso
*/
export async function deleteCredential(credentialId: string): Promise<boolean> {
const { apiBaseUrl, apiKey } = getApiConfig();
try {
const response = await fetch(`${apiBaseUrl}/credentials/${credentialId}`, {
method: "DELETE",
headers: {
"X-N8N-API-KEY": apiKey,
},
});
if (response.ok) {
console.log("[n8n-api] Credential deleted:", credentialId);
return true;
}
console.warn("[n8n-api] Failed to delete credential:", response.statusText);
return false;
} catch (error) {
console.error("[n8n-api] Error deleting credential:", error);
return false;
}
}
/**
* Atualizar ou criar credencial Google OAuth no n8n
*
* Usa o ID da credencial existente (N8N_GOOGLE_CREDENTIAL_ID) para atualizar.
* Se o ID não existir ou falhar, tenta criar uma nova.
* Estratégia: DELETE + POST (não existe GET/PUT na API do n8n)
* 1. Se oldCredentialId é fornecido, deleta a credencial antiga
* 2. Cria nova credencial "refugio" com os dados atualizados
* 3. Retorna o novo ID da credencial
*
* IMPORTANTE: Sempre deleta a credencial antiga antes de criar nova
* para garantir que o nome "refugio" esteja sempre atualizado.
*
* @param credentialName - Nome da credencial (ex: "refugio")
* @param clientId - Google Client ID
* @param clientSecret - Google Client Secret
* @param scopes - Escopos OAuth separados por espaço
* @param accessToken - Access token do OAuth (opcional, para atualização)
* @param refreshToken - Refresh token do OAuth (opcional, para atualização)
* @param accessToken - Access token do OAuth (opcional, para incluir tokens na criação)
* @param refreshToken - Refresh token do OAuth (opcional, para incluir tokens na criação)
* @param expiresIn - Tempo de expiração em segundos (opcional)
* @returns Credencial criada/atualizada com ID
* @param oldCredentialId - ID da credencial anterior para deletar (opcional)
* @returns Credencial criada com novo ID
*/
export async function upsertGoogleCredential(
credentialName: string,
@ -67,10 +103,22 @@ export async function upsertGoogleCredential(
accessToken?: string,
refreshToken?: string,
expiresIn?: number,
oldCredentialId?: string | null,
) {
const { apiBaseUrl, apiKey } = getApiConfig();
const credentialId = process.env.N8N_GOOGLE_CREDENTIAL_ID;
// Passo 1: Deletar credencial antiga (se fornecido oldCredentialId ou se existe no .env)
const credentialToDelete =
oldCredentialId || process.env.N8N_GOOGLE_CREDENTIAL_ID;
if (credentialToDelete) {
console.log("[n8n-api] Deleting existing credential:", credentialToDelete);
await deleteCredential(credentialToDelete);
} else {
console.log("[n8n-api] No existing credential to delete");
}
// Passo 2: Preparar payload para nova credencial
// Payload base com campos obrigatórios do schema googleCalendarOAuth2Api
// Referência: n8n API schema validation (obtido via GET /credentials/schema/googleCalendarOAuth2Api)
// NOTA: Apesar do schema indicar que sendAdditionalBodyProperties/additionalBodyProperties
@ -89,8 +137,8 @@ export async function upsertGoogleCredential(
},
};
// Payload para atualização (PUT) pode incluir tokens via oauthTokenData
const updatePayload = accessToken
// Se temos tokens, incluir no payload de criação
const createPayload = accessToken
? {
...basePayload,
data: {
@ -106,43 +154,15 @@ export async function upsertGoogleCredential(
}
: basePayload;
// Se temos um credential ID, tentar atualizar primeiro
if (credentialId) {
try {
const updateResponse = await fetch(
`${apiBaseUrl}/credentials/${credentialId}`,
{
method: "PUT",
headers: {
"X-N8N-API-KEY": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(updatePayload),
},
);
if (updateResponse.ok) {
console.log("[n8n-api] Credential updated via PUT:", credentialId);
return { id: credentialId, ...(await updateResponse.json()) };
}
console.warn(
"[n8n-api] PUT failed, will try POST:",
updateResponse.statusText,
);
} catch (error) {
console.warn("[n8n-api] PUT error, will try POST:", error);
}
}
// Se não tem ID ou PUT falhou, criar nova credencial (apenas com campos básicos)
// Passo 3: Criar nova credencial
console.log('[n8n-api] Creating new credential "refugio"');
const createResponse = await fetch(`${apiBaseUrl}/credentials`, {
method: "POST",
headers: {
"X-N8N-API-KEY": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(basePayload),
body: JSON.stringify(createPayload),
});
if (!createResponse.ok) {
@ -151,7 +171,12 @@ export async function upsertGoogleCredential(
}
const result = await createResponse.json();
console.log("[n8n-api] Credential created via POST:", result.id);
console.log("[n8n-api] Credential created successfully:", result.id);
console.log(
"[n8n-api] IMPORTANTE: Atualize N8N_GOOGLE_CREDENTIAL_ID no .env.local para:",
result.id,
);
return result;
}

View File

@ -0,0 +1,37 @@
/**
* Script para deletar credenciais órfãs no n8n
* Execute com: npx tsx scripts/delete-orphan-credentials.ts
*/
import { deleteCredential } from "../lib/n8n-api";
// IDs das credenciais órfãs detectadas nos logs
const orphanCredentials = [
"3FXa3YeWaFobE7fJ", // Primeira tentativa
"6upnlkiEcqiKXcjv", // Segunda tentativa
];
async function deleteOrphans() {
console.log("🧹 Iniciando limpeza de credenciais órfãs...\n");
for (const credentialId of orphanCredentials) {
console.log(`Deletando credencial: ${credentialId}`);
try {
const result = await deleteCredential(credentialId);
if (result) {
console.log(`✅ Credencial ${credentialId} deletada com sucesso`);
} else {
console.log(
`⚠️ Falha ao deletar ${credentialId} (pode não existir mais)`,
);
}
} catch (error) {
console.error(`❌ Erro ao deletar ${credentialId}:`, error);
}
console.log("");
}
console.log("✅ Limpeza concluída!");
}
deleteOrphans().catch(console.error);

View File

@ -0,0 +1,43 @@
{
"additionalProperties": false,
"type": "object",
"properties": {
"clientId": { "type": "string" },
"clientSecret": { "type": "string" },
"sendAdditionalBodyProperties": { "type": "boolean" },
"additionalBodyProperties": { "type": "json" },
"allowedHttpRequestDomains": {
"type": "string",
"enum": ["all", "domains", "none"]
},
"allowedDomains": { "type": "string" },
"oauthTokenData": { "type": "json" }
},
"allOf": [
{
"if": {
"properties": { "grantType": { "enum": ["clientCredentials"] } }
},
"then": {
"allOf": [
{ "required": ["sendAdditionalBodyProperties"] },
{ "required": ["additionalBodyProperties"] }
]
},
"else": {
"allOf": [
{ "not": { "required": ["sendAdditionalBodyProperties"] } },
{ "not": { "required": ["additionalBodyProperties"] } }
]
}
},
{
"if": {
"properties": { "allowedHttpRequestDomains": { "enum": ["domains"] } }
},
"then": { "allOf": [{ "required": ["allowedDomains"] }] },
"else": { "allOf": [{ "not": { "required": ["allowedDomains"] } }] }
}
],
"required": ["clientId", "clientSecret"]
}