mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 09:16:46 +00:00
Concluída Etapa F
X-Lovable-Edit-ID: edt-5bfa46d0-a5b1-486f-b26b-b82237ef74c5 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
39902d9b2e
5 changed files with 329 additions and 1 deletions
76
README.md
76
README.md
|
|
@ -79,6 +79,82 @@ A Edge Function `telegram-webhook` hoje responde com um placeholder. O `TODO Fas
|
|||
|
||||
---
|
||||
|
||||
## Fase 4 — Integrações OAuth + Automações (✅ entregue)
|
||||
|
||||
### O que foi entregue
|
||||
|
||||
- **5 providers OAuth** cadastrados em `available_mcps`: Google Workspace, Notion, Todoist, Cal.com, Microsoft 365
|
||||
- **Edge Functions**:
|
||||
- `oauth-start` — gera state token + URL de autorização (PKCE quando aplicável)
|
||||
- `oauth-callback` (público, `verify_jwt = false`) — troca code por tokens, persiste no Vault
|
||||
- `refresh-integration-token` — renova access_token via refresh_token
|
||||
- `disconnect-integration` — revoga no provider + apaga do Vault + auto-pausa cronjobs dependentes
|
||||
- `test-integration` — verifica conectividade sem consumir refresh
|
||||
- `parse-cronjob-natural-language` — Lovable AI Gateway (gemini-2.5-flash) → cron + prompt
|
||||
- **Frontend `/painel/integracoes`** — grid com 4 estados (available, locked, connected, error), página de detalhe com `DisconnectMCPDialog` que lista cronjobs dependentes
|
||||
- **Frontend `/painel/cronjobs`** — wizard 3 passos (NL → revisão obrigatória → checagem de dependências MCP), gestão de status (active/paused/auto_paused)
|
||||
- **Dashboard** — widgets de Skills, Automações e Integrações + banner de auto-pausa
|
||||
- **Enforcement via banco** — views `user_jobs_limits` e `user_integration_limits` aplicam limites por plano
|
||||
- **Cleanup oauth_states** — trigger `FOR EACH STATEMENT` (não FOR EACH ROW) limpa tokens expirados
|
||||
|
||||
### Etapas pós-deploy (uma única vez)
|
||||
|
||||
#### 1. Configurar OAuth apps em cada provider
|
||||
|
||||
Para cada provider abaixo, crie um OAuth app e configure a **Redirect URI**:
|
||||
|
||||
```
|
||||
https://smsarmgoirlcedmqvdgc.supabase.co/functions/v1/oauth-callback
|
||||
```
|
||||
|
||||
| Provider | Console | Scopes mínimos |
|
||||
|----------|---------|----------------|
|
||||
| **Google Workspace** | [console.cloud.google.com](https://console.cloud.google.com) → APIs & Services → Credentials → OAuth 2.0 Client ID (Web app) | `openid email profile https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar` |
|
||||
| **Microsoft 365** | [Azure Portal](https://portal.azure.com) → App registrations → New registration → Web | `openid email profile offline_access Mail.Read Calendars.ReadWrite` |
|
||||
| **Notion** | [notion.so/my-integrations](https://www.notion.so/my-integrations) → New integration (Public) | (definidos na integração) |
|
||||
| **Todoist** | [developer.todoist.com](https://developer.todoist.com/appconsole.html) → App management → Create app | `data:read_write` |
|
||||
| **Cal.com** | [app.cal.com/settings/developer](https://app.cal.com/settings/developer/oauth-clients) → New OAuth Client | `READ_BOOKING WRITE_BOOKING READ_PROFILE` |
|
||||
|
||||
#### 2. Adicionar os 10 secrets no Lovable Cloud
|
||||
|
||||
Pelo menu **Connectors → Lovable Cloud → Secrets**, adicione:
|
||||
|
||||
```
|
||||
GOOGLE_OAUTH_CLIENT_ID
|
||||
GOOGLE_OAUTH_CLIENT_SECRET
|
||||
MICROSOFT_OAUTH_CLIENT_ID
|
||||
MICROSOFT_OAUTH_CLIENT_SECRET
|
||||
NOTION_OAUTH_CLIENT_ID
|
||||
NOTION_OAUTH_CLIENT_SECRET
|
||||
TODOIST_OAUTH_CLIENT_ID
|
||||
TODOIST_OAUTH_CLIENT_SECRET
|
||||
CALCOM_OAUTH_CLIENT_ID
|
||||
CALCOM_OAUTH_CLIENT_SECRET
|
||||
```
|
||||
|
||||
#### 3. Verificar publicação Realtime
|
||||
|
||||
A view `user_integration_limits` e `user_jobs_limits` usam dados de `user_integrations` e `scheduled_jobs`. Não precisam estar em realtime, mas confirme que os RPCs Vault permanecem restritos ao `service_role`.
|
||||
|
||||
#### 4. Teste end-to-end
|
||||
|
||||
1. Acesse `/painel/integracoes` → conecte uma integração (ex: Notion)
|
||||
2. Acesse `/painel/cronjobs/nova` → descreva "todo dia útil às 9h, criar uma página no Notion com resumo do dia"
|
||||
3. Confirme a revisão (cron + prompt)
|
||||
4. A automação deve aparecer ativa em `/painel/cronjobs`
|
||||
5. Desconecte a integração Notion → a automação deve aparecer **auto-pausada** com banner no dashboard
|
||||
|
||||
### O que **não** está nesta fase
|
||||
|
||||
- ❌ Execução real de cronjobs (Fase 5 — scheduler + Hermes)
|
||||
- ❌ Sync Mika ↔ Container (Fase 5)
|
||||
- ❌ MCPs corporativos privados / BYOA
|
||||
- ❌ Histórico de execuções de cronjobs
|
||||
- ❌ Dashboard de uso de API calls
|
||||
- ❌ Providers além dos 5 listados
|
||||
|
||||
---
|
||||
|
||||
## Comandos úteis
|
||||
|
||||
```bash
|
||||
|
|
|
|||
45
src/components/mika/cronjobs/AutoPausedBanner.tsx
Normal file
45
src/components/mika/cronjobs/AutoPausedBanner.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useCronjobs } from "@/hooks/use-cronjobs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function AutoPausedBanner() {
|
||||
const { data: jobs } = useCronjobs();
|
||||
const autoPaused = (jobs ?? []).filter((j) => j.status === "auto_paused");
|
||||
|
||||
if (autoPaused.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-destructive/40 bg-destructive/10 p-4 flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm text-destructive">
|
||||
{autoPaused.length === 1
|
||||
? "1 automação foi pausada automaticamente"
|
||||
: `${autoPaused.length} automações foram pausadas automaticamente`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Geralmente isso acontece quando uma integração necessária foi desconectada ou expirou.
|
||||
Reconecte a integração ou revise a automação para reativá-la.
|
||||
</p>
|
||||
<ul className="mt-2 text-xs space-y-0.5">
|
||||
{autoPaused.slice(0, 3).map((j) => (
|
||||
<li key={j.id} className="truncate">
|
||||
<Link to="/painel/cronjobs/$id" params={{ id: j.id }} className="text-primary underline">
|
||||
{j.name}
|
||||
</Link>
|
||||
{j.auto_paused_reason && (
|
||||
<span className="text-muted-foreground"> — {j.auto_paused_reason}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button asChild size="sm" variant="outline" className="shrink-0">
|
||||
<Link to="/painel/cronjobs">Ver todas</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/mika/cronjobs/CronjobsDashboardWidget.tsx
Normal file
99
src/components/mika/cronjobs/CronjobsDashboardWidget.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"use client";
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ArrowRight, CalendarClock, Plus, AlertTriangle } from "lucide-react";
|
||||
import { useCronjobs, useUserJobsLimits } from "@/hooks/use-cronjobs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function CronjobsDashboardWidget() {
|
||||
const { data: jobs, isLoading } = useCronjobs();
|
||||
const { data: limits } = useUserJobsLimits();
|
||||
|
||||
if (isLoading) return <Skeleton className="h-48 w-full rounded-xl" />;
|
||||
|
||||
const list = jobs ?? [];
|
||||
const activeCount = list.filter((j) => j.status === "active").length;
|
||||
const autoPausedCount = list.filter((j) => j.status === "auto_paused").length;
|
||||
const recent = list.slice(0, 3);
|
||||
const planAllows = (limits?.max_jobs ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-soft">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<CalendarClock className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Suas automações</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeCount} ativa{activeCount === 1 ? "" : "s"}
|
||||
{limits?.max_jobs ? ` de ${limits.max_jobs} disponíveis` : ""}
|
||||
{autoPausedCount > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-destructive font-medium">
|
||||
{autoPausedCount} auto-pausada{autoPausedCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm" className="text-primary hover:text-primary">
|
||||
<Link to="/painel/cronjobs">
|
||||
Ver todas <ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!planAllows ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seu plano atual não inclui automações.{" "}
|
||||
<Link to="/painel/faturamento" className="text-primary underline">
|
||||
Fazer upgrade
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Crie sua primeira automação descrevendo em português.
|
||||
</p>
|
||||
<Button asChild size="sm" className="rounded-lg">
|
||||
<Link to="/painel/cronjobs/nova">
|
||||
<Plus className="h-4 w-4 mr-1.5" /> Nova automação
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{recent.map((job) => (
|
||||
<li key={job.id}>
|
||||
<Link
|
||||
to="/painel/cronjobs/$id"
|
||||
params={{ id: job.id }}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background/50 px-3 py-2.5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{job.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{job.human_readable}</p>
|
||||
</div>
|
||||
{job.status === "active" && <Badge variant="success" className="shrink-0">Ativa</Badge>}
|
||||
{job.status === "paused" && <Badge variant="secondary" className="shrink-0">Pausada</Badge>}
|
||||
{job.status === "auto_paused" && (
|
||||
<Badge variant="destructive" className="shrink-0 gap-1">
|
||||
<AlertTriangle className="h-3 w-3" /> Auto
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ArrowRight, Plug, AlertCircle } from "lucide-react";
|
||||
import { useIntegrationCards } from "@/hooks/use-integrations";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export function IntegrationsDashboardWidget() {
|
||||
const { cards, isLoading, limit } = useIntegrationCards();
|
||||
|
||||
if (isLoading) return <Skeleton className="h-48 w-full rounded-xl" />;
|
||||
|
||||
const connected = cards.filter((c) => c.kind === "connected");
|
||||
const errored = cards.filter((c) => c.kind === "error");
|
||||
const recent = [...errored, ...connected].slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-soft">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Plug className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Suas integrações</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{connected.length} conectada{connected.length === 1 ? "" : "s"}
|
||||
{limit?.max_integrations ? ` de ${limit.max_integrations} disponíveis` : ""}
|
||||
{errored.length > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-destructive font-medium">
|
||||
{errored.length} com erro
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm" className="text-primary hover:text-primary">
|
||||
<Link to="/painel/integracoes">
|
||||
Gerenciar <ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{recent.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Conecte serviços como Gmail, Notion e Cal.com para ampliar seu Mika.
|
||||
</p>
|
||||
<Button asChild size="sm" variant="outline" className="rounded-lg">
|
||||
<Link to="/painel/integracoes">Explorar integrações</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{recent.map((card) => {
|
||||
const mcp = card.mcp;
|
||||
return (
|
||||
<li key={mcp.id}>
|
||||
<Link
|
||||
to="/painel/integracoes/$slug"
|
||||
params={{ slug: mcp.slug }}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background/50 px-3 py-2.5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<img src={mcp.icon_url} alt="" className="h-6 w-6 rounded shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{mcp.name}</p>
|
||||
{card.kind === "connected" && card.integration.connected_account_email && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{card.integration.connected_account_email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{card.kind === "connected" && (
|
||||
<Badge variant="success" className="shrink-0">Ativa</Badge>
|
||||
)}
|
||||
{card.kind === "error" && (
|
||||
<Badge variant="destructive" className="shrink-0 gap-1">
|
||||
<AlertCircle className="h-3 w-3" /> Erro
|
||||
</Badge>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,9 @@ import { Button } from "@/components/ui/button";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SubscriptionBanner } from "@/components/mika/SubscriptionBanner";
|
||||
import { SkillsDashboardWidget } from "@/components/mika/SkillsDashboardWidget";
|
||||
import { CronjobsDashboardWidget } from "@/components/mika/cronjobs/CronjobsDashboardWidget";
|
||||
import { IntegrationsDashboardWidget } from "@/components/mika/integrations/IntegrationsDashboardWidget";
|
||||
import { AutoPausedBanner } from "@/components/mika/cronjobs/AutoPausedBanner";
|
||||
import { TelegramOnboardingWizard } from "@/components/mika/telegram/TelegramOnboardingWizard";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -76,7 +79,16 @@ function DashboardPage() {
|
|||
<ProvisioningCard />
|
||||
)}
|
||||
|
||||
{subscription && subscription.status === "active" && <SkillsDashboardWidget />}
|
||||
{subscription && subscription.status === "active" && (
|
||||
<>
|
||||
<AutoPausedBanner />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<SkillsDashboardWidget />
|
||||
<CronjobsDashboardWidget />
|
||||
</div>
|
||||
<IntegrationsDashboardWidget />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TelegramOnboardingWizard open={wizardOpen} onOpenChange={setWizardOpen} />
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue