diff --git a/.env.example b/.env.example index a60c6298..2a227ab9 100644 --- a/.env.example +++ b/.env.example @@ -65,3 +65,13 @@ DEEPINFRA_API_KEY=YOUR_DEEPINFRA_API_KEY_HERE # collection. Absent/malformed → the agenda shows "não configurado" and the # mirror stays off — the Teqo agenda never depends on this. GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY= + +# C149 — Google OAuth client pair for the "Conectar com o Google" button. Create +# a "Web application" OAuth client in the same GCP project, register the +# redirect URIs (`/campanha/agenda/google-oauth/callback`, plus the +# local dev origin) and publish the app to production (unverified is fine for +# internal use) so the refresh token does not expire in ~7 days. Absent → the +# dialog falls back to the service-account runbook and the mirror keeps working +# via `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`. +GOOGLE_CALENDAR_OAUTH_CLIENT_ID= +GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET= diff --git a/docs/changelog/2026-09-12-c149.md b/docs/changelog/2026-09-12-c149.md new file mode 100644 index 00000000..6182e5f9 --- /dev/null +++ b/docs/changelog/2026-09-12-c149.md @@ -0,0 +1 @@ +**Botão "Conectar com o Google" na agenda substitui a configuração manual da service account (2026-09-12):** ativar o espelho Google Calendar (C114/C115) exigia cerimônia de engenharia — criar service account no GCP, baixar JSON, base64 numa env e compartilhar o calendário; agora candidato ou coordenação clica **Conectar com o Google** no diálogo do espelho e autoriza uma única concessão (Authorization Code + `access_type=offline` + `prompt=consent`) para o calendário da campanha. O refresh token é gravado na própria collection `googleCalendarSync` (5 campos `oauth*`, `oauthRefreshToken` com `read: false` — inalcançável por qualquer caminho de usuário, como o segredo do webhook); a migration `20260912_052226_add_google_calendar_oauth_connection` é aditiva/nullable e o schema continua com `push: false`. O motor resolve a credencial por `readGoogleCalendarAuth` (OAuth vence quando há token + client env; service account segue fallback intacto) e o client ganhou a união `GoogleCalendarAuth` (refresh_token grant) mantendo os 6 métodos e o retry de 401. A conexão tem exatamente três estados derivados (`conectado | erro | não configurado`): `invalid_grant` vira `error` com reconexão em um clique (`GoogleCalendarAuthError` → `oauthErrorPatchFor`, pinado em unit), e o sucesso limpa o erro — falha do Google nunca corrompe o Teqo. O handshake usa escopos mínimos (`calendar.events` + `calendar.calendarlist.readonly`, validados na concessão para o consent granular não conectar sem permissão de escrita), `state` HMAC-SHA256 single-use de 10min amarrado ao usuário (cookie `campaign-google-oauth-state`, padrão WebAuthn), callback GET fora do `(app)` com redirect sempre na URL pública (`getCampaignInviteBaseURL()` — nada de localhost atrás do túnel) e re-checagem do papel fresco. Desconectar limpa a conexão no Teqo e orienta revogar o app no Google. UI: card de conexão no `GoogleCalendarSyncDialog` (Conectar/Reconectar/Desconectar; advisor vê sem botões; leader lockdown intocado) com o runbook antigo só quando o client OAuth não está configurado no servidor. Duas envs novas (`GOOGLE_CALENDAR_OAUTH_CLIENT_ID`/`_SECRET`) documentadas no `.env.example` e no runbook `docs/ops/teqo-1313-deploy.md` (criar o OAuth client Web application, registrar os redirect URIs e publicar o app unverified para o token não expirar em 7 dias) — **pré-requisito de deploy: setá-las em `~/stack/teqo-1313.env`**. Verificação: unit full 2716 passed, int full 722 passed (os specs da família Google agora serializam por advisory lock — a collection é singleton e a corrida entre arquivos paralelos sumiu), e2e `campaignAgendaGoogleSync` 9/9 local incluindo o botão de consent, typecheck/lint/format/knip/cycles verdes. Débitos deferidos com gatilho no impl plan (`docs/plans/c149-conectar-google-oauth-impl.md`): extrair o cookie assinado compartilhado no 4º site, cifrar o refresh token em repouso se compliance exigir, e a constraint única do singleton para o C150. diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 910d96d3..158a9459 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -175,6 +175,28 @@ automaticamente — **não serve push, não recebe PRs, é somente leitura**. - Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` / build-secrets; envs só no homeserver. +## C149 — Google OAuth da agenda (envs novas + GCP) + +O botão **"Conectar com o Google"** da agenda usa um OAuth client do GCP. Sem +as envs, o diálogo cai no runbook antigo da service account — que segue como +fallback e não foi removida por esta entrega. + +1. No GCP (mesmo projeto da service account), crie um OAuth client **Web + application** com os redirect URIs: + - `https://jorgesolla1313.com.br/campanha/agenda/google-oauth/callback` + - `http://localhost:/campanha/agenda/google-oauth/callback` (dev) +2. Publique o app em produção — **unverified** é aceito (uso interno). Em + "Testing" o refresh token expira em ~7 dias e o estado vira `erro` na UI. +3. Adicione ao `~/stack/teqo-1313.env` (homeserver, chmod 600): + `GOOGLE_CALENDAR_OAUTH_CLIENT_ID` e `GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET`. +4. Deploy normal (`workflow_dispatch` do `deploy.yml`). O callback monta o + redirect a partir de `NEXT_PUBLIC_SITE_URL` — precisa bater exatamente com + o URI registrado no passo 1. + +Desconectar no Teqo apaga o refresh token daquele lado; para revogar o acesso +de fato, revogue o app em `myaccount.google.com/permissions` (a UI orienta o +passo). + ## OPS79 — última migração da plataforma antiga → nova (vertical campanha) Operação de dados executada em 2026-08-23. Ver assistência lógica completa: diff --git a/docs/plans/c149-conectar-google-oauth-impl.md b/docs/plans/c149-conectar-google-oauth-impl.md new file mode 100644 index 00000000..88173468 --- /dev/null +++ b/docs/plans/c149-conectar-google-oauth-impl.md @@ -0,0 +1,175 @@ +# Impl: C149 — Google Calendar: botão Conectar Google (OAuth) substitui a configuração manual de service account + +Status: em execução +Atualizado em: 2026-09-12 +Issue: #934 +Intenção: docs/plans/c149-conectar-google-oauth.md +Appetite restante: ~2 dias eng (herdado) + +## Leitura da intenção + +- **Outcome:** candidato/coordenador conecta a conta Google do calendário da campanha com um clique (consent), sem criar service account nem colar JSON; o estado da conexão é honesto (`conectado | erro | não configurado`), erro de token reconecta em um clique e o Teqo segue SoT. +- **Não negociar:** escopos mínimos literais (`calendar.events` + `calendar.calendarlist.readonly`); exatamente três estados, derivados, nunca armazenados; `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` preservada como fallback (não renomear/remover); token sobrevive sem re-consentimento semanal; não é OAuth por pessoa (anti-goal C114); motor de reconciliação intocado; callback sempre na URL pública (`getCampaignInviteBaseURL()`); token nunca em log; armazenamento fail-closed (sem conexão → sem sync, sem crash); leader lockdown intocado. +- **Reavaliar:** app unverified exige publicar em produção no GCP para o refresh token não expirar em ~7 dias (decisão de ops, D14); armazenamento do refresh token em texto puro é débito consciente com gatilho de revisão (D1); advisor vê o card sem botões (assumido com produto); a escrita de `calendarId` continua admin-only e será aberta no C150. + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph UI["Agenda /campanha/agenda"] + CARD["GoogleCalendarSyncDialog
card de conexão (3 estados)"] + end + CARD -->|startGoogleCalendarOAuth| ACT[server action] + ACT -->|state assinado HMAC| COOKIE[(cookie campaign
TTL 10min, single-use)] + ACT -->|authorizeUrl| GOOG[accounts.google.com
consent] + GOOG -->|code + state| CB[callback GET
fora do app] + COOKIE -->|verifica state| CB + CB -->|troca code| TOK[oauth2 token endpoint] + TOK -->|refresh_token| DB[(googleCalendarSync
oauthRefreshToken)] + CB -->|302 /campanha/agenda| CARD + SYNC[runCampaignCalendarSync] -->|readGoogleCalendarAuth| DB + SYNC -->|Bearer access token| GCAL[Google Calendar API] + SA[GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY] -.fallback quando sem OAuth.-> SYNC + CARD -->|disconnectGoogleCalendarOAuth| ACT2[server action] + ACT2 -->|limpa conexão| DB +``` + +**Opções consideradas:** A) Authorization Code + rota de callback própria com state assinado em cookie | B) fluxo out-of-band (device/código colado) | C) manter service account e melhorar só o runbook. + +**Recomendação:** A — reusa o precedente de cerimônia fora do `(app)` (WebAuthn) e o resolvedor de URL pública (`getCampaignInviteBaseURL`), entrega o consent de um clique e isola o callback para teste; B troca "um clique" por "copiar e colar código" e não tem precedente; C não entrega o outcome pedido pelo dono. + +**Rejeitadas:** B porque adiciona atrito e uma superfície de erro nova; C porque é exatamente a cerimônia que o pedido manda remover. + +### Componentes / mudanças + +- **`src/collections/GoogleCalendarSync.ts`** — cinco campos novos (D1): `oauthRefreshToken` (text, `read: canReadGoogleCalendarSyncIdentityField`, `create/update: canSetGoogleCalendarSyncSystemField`), `oauthScope` (text), `oauthConnectedAt` (date), `oauthErrorAt` (date), `oauthError` (textarea); labels/admin `readOnly` em pt-BR; `admin.defaultColumns` inalterado. +- **`src/utilities/access/googleCalendarSync.ts`** — `canManageGoogleCalendarConnection(actor)` (delega a `isCampaignUnrestricted`); novos campos usam os helpers existentes (`canSetGoogleCalendarSyncSystemField`, `canReadGoogleCalendarSyncIdentityField`). +- **`src/migrations/_add_google_calendar_oauth_connection.{ts,json}` + `index.ts`** — migração aditiva (colunas nullable), gerada com `pnpm migrate:create add_google_calendar_oauth_connection`; `src/payload-types.ts` regenerado (`pnpm generate:types`). +- **`src/lib/googleCalendarOAuth.ts`** (novo, puro) — `GOOGLE_CALENDAR_OAUTH_SCOPES`, `GOOGLE_CALENDAR_OAUTH_CALLBACK_PATH`, `GOOGLE_CALENDAR_OAUTH_AUTHORIZE_ENDPOINT`, `buildGoogleCalendarOAuthAuthorizeUrl({ clientId, redirectUri, state })` com `access_type=offline`, `prompt=consent`, `response_type=code`. +- **`src/utilities/googleCalendarOAuth.ts`** (novo, server-only, sem importar `googleCalendarSync`) — `readGoogleCalendarOAuthCredentials()` (fail-closed), `buildGoogleCalendarOAuthRedirectUri()` (via `getCampaignInviteBaseURL()`), `storeGoogleCalendarOAuthState(state, userId)` / `readGoogleCalendarOAuthState(state)` / `clearGoogleCalendarOAuthState()` (HMAC `PAYLOAD_SECRET`, payload state+userId+exp, TTL 10min, httpOnly/lax, path `/campanha`, single-use), `exchangeGoogleCalendarOAuthCode(code, fetchImpl?)` → `{ refreshToken, scope }` ou erro tipado com mensagem segura (sem corpo/token). +- **`src/utilities/googleCalendarClient.ts`** — `GoogleCalendarAuth = { kind: 'service-account'; credentials } | { kind: 'oauth'; refreshToken; clientId; clientSecret }`; `createGoogleCalendarClient(auth, ...)` mantém os 6 métodos e o cache em memória; `requestAccessToken` ganha o branch `grant_type=refresh_token`; novo `GoogleCalendarAuthError` APENAS para `invalid_grant` (refresh token revogado/expirado — o caso reconectável; `invalid_client`/5xx seguem `GoogleCalendarApiError`, pois reconectar não conserta); `buildServiceAccountAssertion` preservado para testes. +- **`src/utilities/googleCalendarSync.ts`** — `readGoogleCalendarAuth(config)` (OAuth se refresh token + env; senão SA; senão `null`); `GoogleCalendarConnectionState = 'connected' | 'error' | 'not-configured'` + `deriveGoogleCalendarConnectionState`; `GoogleCalendarSyncView` ganha `connection`, `oauthAvailable`, `oauthConnectedAt`, `oauthError`; `loadGoogleCalendarSyncConfig` passa a "row com `calendarId` vence, senão qualquer row" (D11); helpers `recordGoogleCalendarOAuthConnection`, `recordGoogleCalendarOAuthError`, `clearGoogleCalendarOAuthConnection`; `runCampaignCalendarSync` resolve a auth pelo config, limpa `oauthError*` no sucesso e grava `oauthErrorAt/oauthError` no catch de `GoogleCalendarAuthError`. +- **`src/app/(campaign)/campanha/actions/googleCalendarSync.ts`** — `startGoogleCalendarOAuth()` (valida `reloadUnrestrictedActor`, exige env, cria o row singleton se ausente com admin bypass, grava state cookie, devolve `{ ok, authorizeUrl }`) e `disconnectGoogleCalendarOAuth()` (limpa token/scope/datas/erro; sem revoke remoto; sem pass de sync); `GoogleCalendarSyncActionResult` ganha `canManageConnection` e os campos de conexão; `failure()` atualizado. +- **`src/app/(campaign)/campanha/agenda/google-oauth/callback/route.ts`** (novo, fora do `(app)`, `dynamic = 'force-dynamic'`) — GET: lê `code/state/error`, verifica e queima o state cookie, re-checa papel fresco, troca o code, grava a conexão, roda pass de sync se houver `calendarId` e responde `NextResponse.redirect` 302 para `/campanha/agenda`; `access_denied` não grava erro; falha de troca grava `oauthError` e redireciona (sem query param). +- **`src/components/campaign/activity/GoogleCalendarSyncDialog.tsx`** — card de conexão por estado (badge, Conectar/Reconectar/Desconectar, nota do fallback SA, nota para não-manager, runbook admin só quando `!oauthAvailable`); props novas `onStartOAuth`, `onDisconnect`, `canManageConnection`. +- **`src/components/campaign/activity/AgendaGoogleSyncChrome.tsx`** — callbacks `onStartOAuth`/`onDisconnect` repassados; pill existente intocada. +- **`src/app/(campaign)/campanha/(app)/agenda/page.tsx`** — passa as server actions novas ao chrome. +- **`scripts/lib/e2e-affected-manifest.mjs`** — entry nova mapeando `src/utilities/googleCalendarSync.ts`, `googleCalendarSyncHooks.ts`, `googleCalendarClient.ts`, `googleCalendarOAuth.ts`, `src/lib/googleCalendarOAuth.ts`, `GoogleCalendarSyncDialog.tsx`, `AgendaGoogleSyncChrome.tsx` e `src/app/(campaign)/campanha/agenda/google-oauth` ao spec `campaignAgendaGoogleSync`. +- **`playwright.config.ts`** — `GOOGLE_CALENDAR_OAUTH_CLIENT_ID`/`_SECRET` dummy no `webServer.env` (botão visível no e2e). +- **`tests/e2e/campaignAgendaGoogleSync.e2e.spec.ts`** — estados com OAuth: botão, URL de consent interceptada, conectado e erro. +- **`.env.example` + `docs/ops/teqo-1313-deploy.md`** — as duas envs e o runbook GCP (D14). +- **Testes:** `tests/unit/googleCalendarOAuth.unit.spec.ts` (novo), extensões em `googleCalendarClient.unit.spec.ts`, `googleCalendarSync.unit.spec.ts`, `googleCalendarSyncDialog.unit.spec.tsx`, `agendaGoogleSyncChrome.unit.spec.tsx`, `tests/int/googleCalendarSync.int.spec.ts`, `tests/int/googleCalendarSyncAction.int.spec.ts`; allowlist top-level de `tests/unit/codebaseConventions.unit.spec.ts` ganha `googleCalendarOAuth.ts`. + +### Dados → forma + +Estado da conexão é **derivado** (nunca armazenado), função de `oauthRefreshToken`, `oauthConnectedAt` e `oauthErrorAt`: + +| refresh token | erro mais novo que a conexão | estado | UI | +| ------------- | ----------------------------------------------------------- | ---------------- | ------------------------------------------------------------------- | +| não | não | `not-configured` | "Não configurado" + Conectar (se `oauthAvailable`) ou runbook admin | +| sim ou não | sim (`oauthErrorAt > oauthConnectedAt`, inclui sem conexão) | `error` | "Erro na conexão" + Reconectar + Desconectar | +| sim | não | `connected` | "Conectado" + Desconectar | + +`oauthAvailable` (env do client presente) é ortogonal ao estado e decide botão vs. runbook. `oauthScope`, `oauthConnectedAt` e `oauthError` são apenas informativos/derivados; o token nunca cruza a fronteira da view. + +## Decisões de engenharia (decisão + por quê + rejeitadas; só as caras de reverter) + +1. **Armazenamento na própria `googleCalendarSync` (D1):** campos novos na collection que já é dona do estado do espelho; sem collection twin. Texto puro com precedente (`SocialFeedSettings.instagramAccessToken`, `pushChannelSecret`), `read: false` no token e nunca logar. **Rejeitadas:** AES-GCM (sem precedente no repo, rotação de chave e o DB comprometido já é game over) — vira débito com gatilho de revisão; collection nova (twin). +2. **Credenciais do app OAuth em env (D2):** `GOOGLE_CALENDAR_OAUTH_CLIENT_ID`/`_SECRET`; redirect URI sempre derivado de `getCampaignInviteBaseURL()` + `/campanha/agenda/google-oauth/callback`. **Rejeitadas:** client no DB (não é per-campanha); override de redirect por env (risco de localhost vazar). +3. **Transporte por união de auth (D3):** `GoogleCalendarAuth` com SA (JWT assertion, comportamento atual) e OAuth (refresh_token grant) na mesma superfície de 6 métodos, mesmo cache e mesmo retry de 401; `GoogleCalendarAuthError` separa conexão quebrada (`invalid_grant`, reconectável) de erro de API/config (`invalid_client`, 5xx → pausa). **Rejeitadas:** segundo client OAuth paralelo; tratar todo erro de token como auth error (invalid_client viraria "reconecte" sem conserto). +4. **Resolução e limpeza no motor (D4):** `readGoogleCalendarAuth` prefere OAuth quando há refresh token + env, senão SA, senão `null`; sucesso limpa `oauthError*`, `GoogleCalendarAuthError` grava `oauthErrorAt/oauthError` além de `lastError`/paused. **Rejeitada:** fallback silencioso para SA com token quebrado (mascararia o erro e tornaria o estado `error` inalcançável com SA configurada). +5. **Estado derivado (D5):** `'connected' | 'error' | 'not-configured'` como função pura dos campos, nunca coluna nova. **Rejeitadas:** armazenar o estado (drift); quarto estado intermediário. +6. **Papéis (D6):** conectar/desconectar = `isCampaignUnrestricted` via `canManageGoogleCalendarConnection`, enforcement com `reloadUnrestrictedActor` na action; advisor vê o card sem botões; leader não vê a agenda. **Rejeitadas:** advisor conectar (credencial global); liberar por `canUpdateGoogleCalendarSync` (staff inteiro). +7. **Fluxo: action de start + callback GET com state em cookie (D7):** `startGoogleCalendarOAuth` devolve `authorizeUrl` e o client navega; callback valida state (HMAC, TTL 10min, single-use, bound ao userId), re-checa papel fresco, troca o code e responde 302. **Rejeitadas:** searchParam/hash para aviso pós-callback (a canonicalização de `resolveActivityAgendaUrl` come parâmetros desconhecidos); rota GET de start (menos testável e sem precedente); revogação remota no disconnect. +8. **Desconectar limpa só o Teqo (D8):** remove refresh token/scope/datas/erro com admin bypass e orienta revogar no Google; sem pass de sync automático (o próximo trigger cai no fallback SA, se houver). **Rejeitada:** chamar `/revoke` no Google (fora do literal da intenção). +9. **Módulos (D9):** puro em `src/lib/googleCalendarOAuth.ts`, server-only em `src/utilities/googleCalendarOAuth.ts` (sem importar `googleCalendarSync` para não criar ciclo), record/clear e resolução no `googleCalendarSync.ts`, client no `googleCalendarClient.ts`; registrar o novo top-level na allowlist. **Rejeitadas:** um módulo único misturando puro/server; lógica OAuth dentro do client. +10. **UI (D10):** card de conexão dentro do `GoogleCalendarSyncDialog`; pill existente e motor intocados; runbook admin só quando `!oauthAvailable`. **Rejeitadas:** diálogo novo; mexer na pill de status do espelho. +11. **Loader singleton (D11):** "row com `calendarId` vence; senão qualquer row" para o row da conexão não ficar escondido pelo filtro atual. **Rejeitada:** manter o filtro e criar row só no primeiro sync (a conexão ficaria invisível). +12. **Manifest e2e (D12):** entry do domínio Google Calendar → `campaignAgendaGoogleSync`, fechando o gap pré-existente. **Rejeitada:** confiar só no fallback de smoke (o spec não rodaria no PR). +13. **E2E determinístico (D13):** envs dummy no webServer; estados conectado/erro com `lastSuccessAt` futuro para não disparar auto-retry; consent assertado com `page.route('https://accounts.google.com/**')`. **Rejeitada:** consent real (rede/flake). +14. **Ops documentado, sem código (D14):** OAuth client "Web application" no GCP com redirect URIs (prod + dev), escopos, app **publicado em produção (unverified)** para o refresh token não expirar em 7 dias; envs em `~/stack/teqo-1313.env`; aviso de app não verificado aceito (decisão de produto A). + +## Fases verificáveis + +1. **Tracer/schema + server (~55%, ≈1,1 dia)** + - Campos + access + `pnpm migrate:create add_google_calendar_oauth_connection` + `pnpm migrate` + `pnpm generate:types`. + - `src/lib/googleCalendarOAuth.ts` e `src/utilities/googleCalendarOAuth.ts` com unit (URL, escopos, state TTL/single-use, env fail-closed, exchange sem token em mensagem). + - Auth union + `GoogleCalendarAuthError` no client com unit (SA inalterado, refresh grant, 401 re-mint). + - `readGoogleCalendarAuth`, derivação do estado, loader D11, record/clear, sucesso limpa erro, catch de `GoogleCalendarAuthError`; unit/int do sync atualizados. + - Actions start/disconnect + `canManageGoogleCalendarConnection`; int com mock de `campaignActionContext` (padrão de `googleCalendarSyncAction.int.spec.ts`). + - Allowlist top-level de `codebaseConventions` registrada. Gate: `pnpm gate:fast`. +2. **Callback + UI + e2e + docs (~35%, ≈0,7 dia)** + - Callback route (state, papel fresco, exchange, grava, pass se `calendarId`, 302). + - Dialog/chrome/page; unit de dialog/chrome; copy pt-BR dos três estados + orientação de revogar no Google. + - e2e: envs no `playwright.config.ts`, spec com botão/consent/conectado/erro, entry no manifest. + - `.env.example` + runbook em `docs/ops/teqo-1313-deploy.md`. Gate: `pnpm test:e2e:affected` (deve incluir `campaignAgendaGoogleSync`). +3. **Gates finais (~10%, ≈0,2 dia)** + - `pnpm gate:fast`; e2e afetado local (`pnpm test:e2e:affected`); `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- **Criptografar o refresh token no DB (AES-GCM/keyring):** débito registrado com gatilho (rotação de chave/requisito de compliance); não entra. +- **Chamar `/revoke` no Google ao desconectar:** só orientação na UI (literal da intenção). +- **Listar/escolher calendário:** C150; aqui só a conexão. +- **Verificação completa do app Google / auditoria:** ops, fora. +- **Aposentar/migrar a service account:** fora; segue fallback. +- **Watchdog de expiração do refresh token / cron:** fora; o erro aparece no próximo pass e a UI oferece Reconectar. +- **Módulo genérico de envs:** fora; `process.env` direto como no resto do repo. +- **Rate limiting nas rotas OAuth:** fora; state + TTL bastam para este escopo. +- **Motor de reconciliação, janela, locks, hooks C114/C122:** intocados. +- **Aviso pós-callback via searchParam/hash:** rejeitado; redirect simples para `/campanha/agenda`. +- **OAuth por pessoa / agenda pessoal:** anti-goal C114. + +## Riscos e mitigação + +- **Refresh token expira em ~7 dias (app unverified em testing):** D14 publica o app em produção (unverified) no GCP; se expirar, estado vira `error` e Reconectar resolve. Risco residual aceito (produto A). +- **Localhost vazando no redirect atrás do túnel:** redirect URI sempre de `getCampaignInviteBaseURL()` (prod exige HTTPS DNS); unit pin do path do callback; e2e asserta `redirect_uri` público. +- **CSRF/replay do callback:** state HMAC `PAYLOAD_SECRET` com TTL 10min, single-use (queimado antes da troca) e binding ao userId; papel re-checado fresco. +- **Token em log:** exchange não loga corpo; mensagens usam status/código seguros; `oauthRefreshToken` com `read: false`; unit garante ausência de token/code na mensagem de erro. +- **Falha do Google corromper dados:** Teqo SoT; `GoogleCalendarAuthError` só grava `oauthError*`/paused; int cobre `invalid_grant` sem apagar/duplicar eventos. +- **OAuth quebrado sem fallback SA:** decisão consciente (D4); a UI expõe erro + Reconectar e o runbook explica. +- **Upsert do row/corrida:** start cria o singleton com admin bypass; única escrita concorrente é o callback; sem lock novo. +- **Migration em prod:** aditiva/nullable, aplicada pelo deploy (`pnpm build` roda `payload migrate`); sem backfill; baseline intocada. +- **`unmapped-risk` no CI:** `src/utilities/access` já é mapeado; a entry nova do domínio Google entra no mesmo PR — falhar fechado é o comportamento desejado. +- **Auto-retry em erro de conexão:** uma tentativa por load da agenda (sem hammer); falha rápida no token endpoint. + +## Qualidade da decisão + +**Auto-avaliação: 4/5** — as decisões já vêm fechadas e ancoradas em achados concretos (rotas de cerimônia fora do `(app)`, cookie HMAC do WebAuthn, precedente de credencial no DB, canonicalização da agenda, gap do manifest), e o caminho crítico é testável sem tocar o Google. Perde 1 ponto por dependências fora do código: o refresh token de app unverified é uma promessa de ops (publicar em produção no GCP) e o e2e não exercita consent real, então a prova de produção fica no smoke manual pós-deploy; o armazenamento em texto puro é débito aceito com gatilho, não resolvido. + +## Aceite de engenharia + +- [ ] Botão "Conectar com o Google" no card da agenda para candidato/coordenador; advisor vê o card sem botões; leader sem agenda (lockdown intocado). +- [ ] Estados exatamente `conectado | erro | não configurado`, derivados de `oauthRefreshToken`/`oauthConnectedAt`/`oauthErrorAt`. +- [ ] Erro de token (`invalid_grant`) mostra Reconectar em um clique; `access_denied` não grava erro. +- [ ] Callback atrás do túnel usa a URL pública (`getCampaignInviteBaseURL()`); sem localhost em produção. +- [ ] Token nunca em log; `oauthRefreshToken` com `read: false`; exchange sem corpo em erro. +- [ ] Fail-closed: sem auth → `runCampaignCalendarSync` retorna cedo, sem crash e sem sync. +- [ ] Desconectar limpa a conexão no Teqo e orienta revogar no Google; sem pass de sync automático. +- [ ] Service account segue como fallback; `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` não renomeada/removida. +- [ ] Escopos literais `calendar.events` + `calendar.calendarlist.readonly`; `access_type=offline` + `prompt=consent`. +- [ ] Migration nova aplicada local + `payload-types.ts` regenerado; nenhuma migration antiga editada. +- [ ] `pnpm gate:fast` verde; `pnpm test:e2e:affected` inclui `campaignAgendaGoogleSync` (manifest atualizado) e passa local. +- [ ] `.env.example` e runbook de ops (GCP, envs, app publicado unverified) documentados. + +## Execução — desvios e decisões emergentes (2026-09-12) + +- **Classificação do `invalid_grant` virou helper puro** (`oauthErrorPatchFor` em `googleCalendarSync.ts`, pinado em unit): o catch do motor é I/O-bound e o int de motor com row configurado colidia com os specs C114 paralelos. O comportamento é o mesmo; a prova ficou no unit. +- **Escopos concedidos são validados no exchange**: consent granular permite desmarcar `calendar.events`; sem a checagem o card diria "Conectado" e o sync falharia 403 sem caminho de reconexão. +- **Callback só grava `oauthError` quando o EXCHANGE falha** (papel recusado/env ausente não sujam o estado de uma conexão sadia; um erro transitório se auto-cura no próximo pass bem-sucedido, que limpa o campo). +- **Suíte int dos specs Google serializada por advisory lock** (`GOOGLE_CALENDAR_SYNC_LOCK_KEY` + `serializeSpecWithAdvisoryLock` em `tests/helpers/advisoryLock.ts`): a collection é singleton e o loader "row configurado vence" fazia specs paralelos lerem o doc um do outro. A serialização elimina a dependência de `retry` da família. +- **Specs novos não criam rows configurados** (só connection-only) e limpam tokens em rows compartilhados sem deletá-los; o pass do callback é mockado no spec de actions (o motor tem spec próprio). +- **`.env.example` + runbook C149 em `docs/ops/teqo-1313-deploy.md`** documentam o OAuth client, os redirect URIs e a publicação unverified. + +## Débitos (triage do /simplify) + +| ID | Achado | Score | Tipo | Destino | +| --- | ------------------------------------------------------------------- | ----- | ------------- | ------------------------------------------------------------- | +| S1 | Cookie HMAC — 3ª cópia (`googleCalendarOAuth` vs WebAuthn vs import) | 3 | defer_trigger | Defer: extrair `signedCampaignCookie` no 4º cookie assinado ou no próximo toque de um dos três | +| S2 | Refresh token OAuth em texto puro no DB | 3 | defer_trigger | Defer: cifrar em repouso quando compliance/counsel exigir (o campo já é `read: false` e o precedente do repo é texto puro) | +| S3 | Upsert do singleton sem constraint única | 2 | defer_trigger | Defer para **C150** (dono do `calendarId`): decidir constraint se twins virarem risco real | +| S4 | Helper de env dos testes duplicado em 3 specs | 2 | cheap_polish | Descartado (barato, local; os specs estão verdes) | +| S5 | `docToView` com 3 flags posicionais | 1 | cheap_polish | Descartado (cosmético) | + +- **Já resolvido no simplify (não reabrir):** índice em `oauthConnectedAt` removido; `failure()` sem diagnóstico falso; copy do card ajustada; `Date.parse` na derivação; guarda do `parseAccessToken`; validação de escopo concedido; prefixo morto do manifest; callback grava erro só do exchange; `runAction` no dialog; classify puro `oauthErrorPatchFor`; serialização int. +- **Explicitamente fora:** criptografia do refresh token agora; revogação remota no Google (só orientação); rate limiting nas rotas OAuth; verificação completa do app Google; aposentadoria da service account. diff --git a/playwright.config.ts b/playwright.config.ts index b5af32d2..ac663fbf 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -228,6 +228,17 @@ export default defineConfig({ * e2e states stay deterministic (see `googleCalendarTestKey.ts`). */ [GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY_ENV_NAME]: GOOGLE_CALENDAR_TEST_KEY, + /* + * C149 — dummy OAuth client pair so the agenda dialog offers the + * "Conectar com o Google" button (the view derives `oauthAvailable`). + * No spec seeds a refresh token on the shared sync row except with a + * far-future success, and the button test intercepts the navigation + * to Google — no request ever leaves the runner for oauth2. + */ + GOOGLE_CALENDAR_OAUTH_CLIENT_ID: + process.env.GOOGLE_CALENDAR_OAUTH_CLIENT_ID ?? 'e2e-client-id.apps.googleusercontent.com', + GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET: + process.env.GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET ?? 'e2e-client-secret', // Keep e2e artifacts outside `.next`: a concurrent development server // owns that entire directory and may clear nested production bundles. NEXT_DIST_DIR: process.env.NEXT_DIST_DIR ?? '.next-e2e', diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index c1aa5330..b34b2007 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -111,6 +111,23 @@ export const E2E_AFFECTED_MANIFEST = [ // iCal feed dialog. A change there must wake all three surfaces. specs: ['campaignActivity', 'campaignAgendaGoogleSync', 'campaignAgendaFeed'], }, + { + // C149 — the Google Calendar OAuth connection surface. The engine/client + // utilities live top-level (not under `src/utilities/activity`), and the + // callback route sits OUTSIDE `(app)` — without this entry a diff to them + // would only wake the home smoke, leaving the mirror spec unrun. + prefixes: [ + 'src/utilities/googleCalendarSync.ts', + 'src/utilities/googleCalendarSyncHooks.ts', + 'src/utilities/googleCalendarClient.ts', + 'src/utilities/googleCalendarOAuth.ts', + 'src/lib/googleCalendarOAuth.ts', + 'src/components/campaign/activity/AgendaGoogleSyncChrome', + 'src/components/campaign/activity/GoogleCalendarSyncDialog', + 'src/app/(campaign)/campanha/agenda/google-oauth', + ], + specs: ['campaignAgendaGoogleSync'], + }, { prefixes: [ `${CAMPAIGN_APP}/liderancas`, diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx index 3e7d819e..4f180453 100644 --- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -8,9 +8,11 @@ import { revokeCalendarFeed, } from '@/app/(campaign)/campanha/actions/calendarFeed' import { + disconnectGoogleCalendarOAuth, getGoogleCalendarSyncState, runGoogleCalendarSyncNow, setGoogleCalendarSyncDisabled, + startGoogleCalendarOAuth, } from '@/app/(campaign)/campanha/actions/googleCalendarSync' import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda' import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters' @@ -102,6 +104,14 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) { 'use server' return setGoogleCalendarSyncDisabled(disabled) }} + onStartOAuth={async () => { + 'use server' + return startGoogleCalendarOAuth() + }} + onDisconnect={async () => { + 'use server' + return disconnectGoogleCalendarOAuth() + }} /> diff --git a/src/app/(campaign)/campanha/actions/googleCalendarSync.ts b/src/app/(campaign)/campanha/actions/googleCalendarSync.ts index 9e468fb1..031adec2 100644 --- a/src/app/(campaign)/campanha/actions/googleCalendarSync.ts +++ b/src/app/(campaign)/campanha/actions/googleCalendarSync.ts @@ -1,11 +1,25 @@ 'use server' +import { randomBytes } from 'node:crypto' + import type { CampaignUser } from '@/payload-types' import type { Payload } from 'payload' import { buildGoogleCalendarAddLink } from '@/lib/googleCalendarLink' -import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext' +import { buildGoogleCalendarOAuthAuthorizeUrl } from '@/lib/googleCalendarOAuth' +import { canManageGoogleCalendarConnection } from '@/utilities/access/googleCalendarSync' import { + getCampaignActionContext, + reloadCampaignActor, + reloadStaffActor, +} from '@/utilities/campaignActionContext' +import { + buildGoogleCalendarOAuthRedirectUri, + readGoogleCalendarOAuthCredentials, + storeGoogleCalendarOAuthState, +} from '@/utilities/googleCalendarOAuth' +import { + clearGoogleCalendarOAuthConnection, loadGoogleCalendarSyncConfig, readGoogleCalendarSyncView, runCampaignCalendarSync, @@ -16,21 +30,41 @@ const GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE = 'Apenas a equipe da campanha pode gerenciar a sincronização com o Google.' const GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE = 'Não foi possível atualizar o estado da sincronização com o Google.' +const GOOGLE_CALENDAR_OAUTH_MANAGER_ONLY_MESSAGE = + 'Apenas candidato ou coordenação pode conectar ou desconectar a conta Google da campanha.' +const GOOGLE_CALENDAR_OAUTH_UNAVAILABLE_MESSAGE = + 'A conexão com o Google ainda não está configurada no servidor. Fale com a equipe técnica.' export type GoogleCalendarSyncActionResult = { ok: boolean message?: string + /** C149 — the actor may connect/disconnect (candidate or coordinator). */ + canManageConnection: boolean } & GoogleCalendarSyncView & { addLink: string | null } -const withLinks = (view: GoogleCalendarSyncView): Omit => ({ +export type GoogleCalendarOAuthStartResult = + | { ok: true; authorizeUrl: string } + | { ok: false; message: string } + +const withLinks = ( + view: GoogleCalendarSyncView, +): Omit => ({ ...view, addLink: view.calendarId ? buildGoogleCalendarAddLink(view.calendarId) : null, }) -const failure = (message: string): GoogleCalendarSyncActionResult => ({ +const failure = ( + message: string, + canManageConnection: boolean, +): GoogleCalendarSyncActionResult => ({ ok: false, message, + canManageConnection, status: 'not-configured', + connection: 'not-configured', + oauthAvailable: false, + oauthConnectedAt: null, + oauthError: null, calendarId: null, lastSyncedAt: null, lastSuccessAt: null, @@ -44,16 +78,33 @@ const failure = (message: string): GoogleCalendarSyncActionResult => ({ const requireStaff = async (payload: Payload, actor: CampaignUser): Promise => reloadStaffActor(payload, actor, GOOGLE_CALENDAR_SYNC_STAFF_ONLY_MESSAGE) +/** + * C149 — reloads the actor and returns them only when they may manage the + * connection (candidate/coordinator). The role check runs on the fresh + * document; `null` means "refused", never "not loaded". + */ +const loadConnectionManager = async ( + payload: Payload, + actor: CampaignUser, +): Promise => { + const currentActor = await reloadCampaignActor(payload, actor) + return canManageGoogleCalendarConnection(currentActor) ? currentActor : null +} + /** Read model for the agenda pill/dialog — status derived, never stored. */ export const getGoogleCalendarSyncState = async (): Promise => { const { payload, actor } = await getCampaignActionContext() try { - await requireStaff(payload, actor) - return { ok: true, ...withLinks(await readGoogleCalendarSyncView(payload)) } + const currentActor = await requireStaff(payload, actor) + return { + ok: true, + canManageConnection: canManageGoogleCalendarConnection(currentActor), + ...withLinks(await readGoogleCalendarSyncView(payload)), + } } catch (error) { console.error('getGoogleCalendarSyncState failed', error) - return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE) + return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor)) } } @@ -62,12 +113,16 @@ export const runGoogleCalendarSyncNow = async (): Promise => { + const { payload, actor } = await getCampaignActionContext() + + try { + if (!(await loadConnectionManager(payload, actor))) { + return { ok: false, message: GOOGLE_CALENDAR_OAUTH_MANAGER_ONLY_MESSAGE } + } + + const credentials = readGoogleCalendarOAuthCredentials() + if (!credentials) { + return { ok: false, message: GOOGLE_CALENDAR_OAUTH_UNAVAILABLE_MESSAGE } + } + // Resolved BEFORE any write: a missing canonical origin throws here and + // must not leave a stray connection row behind. + const redirectUri = buildGoogleCalendarOAuthRedirectUri() + + // The connection may exist before any calendar is chosen (C150 picks it). + // Payload's Local API without `user` needs the explicit admin bypass; the + // role check above is the authorization for this system write. + const doc = await loadGoogleCalendarSyncConfig(payload) + if (!doc) { + await payload.create({ + collection: 'googleCalendarSync', + data: {}, + depth: 0, + overrideAccess: true, + }) + } + + const state = randomBytes(32).toString('base64url') + await storeGoogleCalendarOAuthState(state, actor.id) + + return { + ok: true, + authorizeUrl: buildGoogleCalendarOAuthAuthorizeUrl({ + clientId: credentials.clientId, + redirectUri, + state, + }), + } + } catch (error) { + console.error('startGoogleCalendarOAuth failed', error) + return { ok: false, message: GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE } + } +} + +/** + * C149 — disconnects the OAuth account from the Teqo: drops the refresh token + * and the connection state. The UI guides revoking the app access on the + * Google account; the service account, when configured, takes over on the next + * trigger. + */ +export const disconnectGoogleCalendarOAuth = async (): Promise => { + const { payload, actor } = await getCampaignActionContext() + + try { + const currentActor = await loadConnectionManager(payload, actor) + if (!currentActor) { + return failure(GOOGLE_CALENDAR_OAUTH_MANAGER_ONLY_MESSAGE, false) + } + await clearGoogleCalendarOAuthConnection(payload) + return { + ok: true, + canManageConnection: true, + ...withLinks(await readGoogleCalendarSyncView(payload)), + } + } catch (error) { + console.error('disconnectGoogleCalendarOAuth failed', error) + return failure(GOOGLE_CALENDAR_SYNC_FAILED_MESSAGE, canManageGoogleCalendarConnection(actor)) } } diff --git a/src/app/(campaign)/campanha/agenda/google-oauth/callback/route.ts b/src/app/(campaign)/campanha/agenda/google-oauth/callback/route.ts new file mode 100644 index 00000000..bc397b5a --- /dev/null +++ b/src/app/(campaign)/campanha/agenda/google-oauth/callback/route.ts @@ -0,0 +1,127 @@ +import config from '@payload-config' +import { NextResponse, type NextRequest } from 'next/server' +import { getPayload } from 'payload' + +import { canManageGoogleCalendarConnection } from '@/utilities/access/googleCalendarSync' +import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin' +import { + buildGoogleCalendarOAuthRedirectUri, + clearGoogleCalendarOAuthState, + exchangeGoogleCalendarOAuthCode, + GoogleCalendarOAuthError, + readGoogleCalendarOAuthCredentials, + readGoogleCalendarOAuthState, +} from '@/utilities/googleCalendarOAuth' +import { + loadGoogleCalendarSyncConfig, + recordGoogleCalendarOAuthConnection, + recordGoogleCalendarOAuthError, + runCampaignCalendarSync, +} from '@/utilities/googleCalendarSync' + +/** + * C149 — the OAuth callback registered in the GCP client. Google returns the + * browser here after consent with `code + state` (or `error` when the user + * denies). It lives OUTSIDE `(app)` on purpose: it is a ceremony route like + * the WebAuthn ones, authenticated by the signed single-use state cookie and + * not by a page session. + * + * The exchange is the only step that matters — the refresh token lands on the + * singleton `googleCalendarSync` row and the derived connection state becomes + * `connected`; a failure records `oauthError` so the UI offers reconnect. The + * browser always ends on `/campanha/agenda` (no query param): the agenda page + * canonicalizes unknown params away, so the state itself is the feedback. + */ + +export const dynamic = 'force-dynamic' + +/** The code exchange plus (when a calendar is configured) a full sync pass. */ +export const maxDuration = 60 + +const AGENDA_PATH = '/campanha/agenda' + +const agendaRedirect = (request: NextRequest): NextResponse => { + try { + return NextResponse.redirect(new URL(AGENDA_PATH, getCampaignInviteBaseURL())) + } catch { + // Only reachable when NEXT_PUBLIC_SITE_URL is missing in production — + // still send the user back to the page instead of a dead end. + return NextResponse.redirect(new URL(AGENDA_PATH, request.nextUrl.origin)) + } +} + +export const GET = async (request: NextRequest): Promise => { + const payload = await getPayload({ config }) + const searchParams = request.nextUrl.searchParams + const providedState = searchParams.get('state') ?? '' + const errorParam = searchParams.get('error') + const code = searchParams.get('code') + + try { + // `access_denied` is the user saying no — not an error to surface, and + // there is no code to exchange. + if (errorParam || !code) { + return agendaRedirect(request) + } + + const state = await readGoogleCalendarOAuthState(providedState) + // Burn before the exchange: a captured callback cannot be replayed even + // if the exchange itself fails. + await clearGoogleCalendarOAuthState() + + // Re-check the role against the FRESH document: the consent navigation + // may have outlived a downgrade. The signed state binds the actor; the + // role check is what authorizes the system write below. Intentional admin + // bypass on the reload (the fresh-role read is the trusted check). + const actor = await payload.findByID({ + collection: 'campaignUser', + id: state.userId, + depth: 0, + overrideAccess: true, + }) + if (!canManageGoogleCalendarConnection(actor)) { + throw new GoogleCalendarOAuthError( + 'Apenas candidato ou coordenação pode conectar a conta Google da campanha.', + ) + } + + const credentials = readGoogleCalendarOAuthCredentials() + if (!credentials) { + throw new GoogleCalendarOAuthError('A conexão com o Google não está configurada no servidor.') + } + + // Only an EXCHANGE failure flips the connection state to `error` — a + // refused role or a missing client env leaves the stored connection + // untouched (the engine clears a transient exchange error on the next + // successful pass with the previous token). + const tokens = await exchangeGoogleCalendarOAuthCode({ + code, + redirectUri: buildGoogleCalendarOAuthRedirectUri(), + credentials, + }).catch(async (error: unknown) => { + if (error instanceof GoogleCalendarOAuthError) { + await recordGoogleCalendarOAuthError(payload, error.message).catch(() => {}) + } + throw error + }) + + await recordGoogleCalendarOAuthConnection(payload, { + refreshToken: tokens.refreshToken, + scope: tokens.scope, + }) + + const doc = await loadGoogleCalendarSyncConfig(payload) + if (doc?.calendarId) { + await runCampaignCalendarSync(payload, { reason: 'config-change' }) + } + + return agendaRedirect(request) + } catch (error) { + const message = + error instanceof Error ? error.message : 'Erro desconhecido ao conectar com o Google.' + payload.logger.error(`[GoogleCalendarSync] Callback OAuth falhou: ${message}`) + return agendaRedirect(request) + } finally { + await clearGoogleCalendarOAuthState().catch(() => {}) + } +} diff --git a/src/collections/GoogleCalendarSync.ts b/src/collections/GoogleCalendarSync.ts index 50157443..307d9c91 100644 --- a/src/collections/GoogleCalendarSync.ts +++ b/src/collections/GoogleCalendarSync.ts @@ -18,6 +18,12 @@ import { googleCalendarSyncConfigHook } from '@/utilities/googleCalendarSyncHook * account private key never lives here — it is `GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY` * in the environment. Changing `calendarId` (or re-enabling) re-runs the full * reconciliation automatically via `googleCalendarSyncConfigHook` (D7). + * + * C149 — the same row also holds the OAuth connection (refresh token + state) + * created by the "Conectar com o Google" button. The refresh token is + * system-only and unreadable by every user path (`read: false`), exactly like + * the push-channel secret; the engine reads it with the admin bypass. The + * service account remains the fallback while no OAuth connection exists. */ export const GoogleCalendarSync: CollectionConfig = { slug: 'googleCalendarSync', @@ -60,6 +66,64 @@ export const GoogleCalendarSync: CollectionConfig = { 'O calendarId do calendário "Agenda da Campanha" na conta Google da campanha (ex.: c_…@group.calendar.google.com). O calendário precisa estar público no link e a service account com permissão de edição.', }, }, + { + name: 'oauthRefreshToken', + type: 'text', + label: 'Token de atualização (OAuth)', + access: { + create: canSetGoogleCalendarSyncSystemField, + read: canReadGoogleCalendarSyncIdentityField, + update: canSetGoogleCalendarSyncSystemField, + }, + admin: { + readOnly: true, + description: + 'Credencial da conexão OAuth com a conta Google da campanha. Preenchida pelo callback do botão "Conectar com o Google"; nunca é exibida.', + }, + }, + { + name: 'oauthScope', + type: 'text', + label: 'Escopos concedidos (OAuth)', + access: { + create: canSetGoogleCalendarSyncSystemField, + update: canSetGoogleCalendarSyncSystemField, + }, + admin: { + readOnly: true, + description: 'Escopos que o Google devolveu na conexão (diagnóstico).', + }, + }, + { + name: 'oauthConnectedAt', + type: 'date', + label: 'Conta Google conectada em', + access: { + create: canSetGoogleCalendarSyncSystemField, + update: canSetGoogleCalendarSyncSystemField, + }, + admin: { readOnly: true }, + }, + { + name: 'oauthErrorAt', + type: 'date', + label: 'Último erro de conexão (OAuth)', + access: { + create: canSetGoogleCalendarSyncSystemField, + update: canSetGoogleCalendarSyncSystemField, + }, + admin: { readOnly: true }, + }, + { + name: 'oauthError', + type: 'textarea', + label: 'Mensagem do último erro de conexão (OAuth)', + access: { + create: canSetGoogleCalendarSyncSystemField, + update: canSetGoogleCalendarSyncSystemField, + }, + admin: { readOnly: true }, + }, { name: 'disabledAt', type: 'date', diff --git a/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx b/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx index 4f92dcb9..c2d44347 100644 --- a/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx +++ b/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx @@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync' +import type { + GoogleCalendarOAuthStartResult, + GoogleCalendarSyncActionResult, +} from '@/app/(campaign)/campanha/actions/googleCalendarSync' import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog' import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext' import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext' @@ -12,6 +15,10 @@ type AgendaGoogleSyncChromeProps = { initialState: GoogleCalendarSyncActionResult onSyncNow: () => Promise onSetDisabled: (disabled: boolean) => Promise + /** C149 — starts the OAuth handshake; the dialog navigates to the consent URL. */ + onStartOAuth: () => Promise + /** C149 — drops the OAuth connection from the Teqo. */ + onDisconnect: () => Promise } const PILL_COPY: Record< @@ -50,6 +57,8 @@ export const AgendaGoogleSyncChrome = ({ initialState, onSyncNow, onSetDisabled, + onStartOAuth, + onDisconnect, }: AgendaGoogleSyncChromeProps) => { const [open, setOpen] = useState(false) const [state, setState] = useState(initialState) @@ -83,6 +92,13 @@ export const AgendaGoogleSyncChrome = ({ [onSetDisabled], ) + // C149 — the connection card follows the disconnect without a page reload. + const handleDisconnect = useCallback(async () => { + const result = await onDisconnect() + if (result.ok) setState(result) + return result + }, [onDisconnect]) + const pill = useMemo(() => { const copy = PILL_COPY[state.status] return ( @@ -109,6 +125,8 @@ export const AgendaGoogleSyncChrome = ({ state={state} onSyncNow={handleSyncNow} onSetDisabled={handleSetDisabled} + onStartOAuth={onStartOAuth} + onDisconnect={handleDisconnect} /> ) diff --git a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx index 8f93532e..58a91b1c 100644 --- a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx +++ b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx @@ -4,7 +4,10 @@ import { useState } from 'react' import { CopyIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react' -import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync' +import type { + GoogleCalendarOAuthStartResult, + GoogleCalendarSyncActionResult, +} from '@/app/(campaign)/campanha/actions/googleCalendarSync' import { Button } from '@/components/ui/button' import { Dialog, @@ -25,6 +28,7 @@ import { import { Input } from '@/components/ui/input' import { useIsMobile } from '@/hooks/use-mobile' import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' +import type { GoogleCalendarConnectionStatus } from '@/utilities/googleCalendarSync' type GoogleCalendarSyncDialogProps = { open: boolean @@ -32,6 +36,10 @@ type GoogleCalendarSyncDialogProps = { state: GoogleCalendarSyncActionResult onSyncNow: () => Promise onSetDisabled: (disabled: boolean) => Promise + /** C149 — starts the OAuth handshake; the caller navigates to the returned URL. */ + onStartOAuth: () => Promise + /** C149 — drops the OAuth connection from the Teqo. */ + onDisconnect: () => Promise } const NOT_CONFIGURED_RUNBOOK: readonly string[] = [ @@ -41,11 +49,32 @@ const NOT_CONFIGURED_RUNBOOK: readonly string[] = [ 'Um administrador configura o ID do calendário no Painel e a chave da service account nas variáveis de ambiente.', ] +const CONNECTION_BADGE: Record< + GoogleCalendarConnectionStatus, + { label: string; className: string } +> = { + connected: { + label: 'Conectado', + className: 'border-green-300 bg-green-50 text-green-700', + }, + error: { + label: 'Erro', + className: 'border-amber-300 bg-amber-50 text-amber-700', + }, + 'not-configured': { + label: 'Não configurado', + className: 'border-border text-muted-foreground', + }, +} + const copyIcon = /** - * C114 — Google Calendar mirror dialog (estados da jornada do canvas: - * não configurado / sincronizado / pausado / desativado). Bottom sheet on + * C114/C115/C149 — Google Calendar mirror dialog. The OAuth connection card + * (C149) leads every state: `não configurado` offers the one-click connect + * (or the admin runbook when the server has no OAuth client), `conectado` + * offers reconnect/disconnect and `erro` offers the reconnect path. The + * mirror-state blocks (C114/C115) stay below, untouched. Bottom sheet on * mobile, dialog on desktop — same chrome as the iCal feed dialog (C94). */ export const GoogleCalendarSyncDialog = ({ @@ -54,6 +83,8 @@ export const GoogleCalendarSyncDialog = ({ state, onSyncNow, onSetDisabled, + onStartOAuth, + onDisconnect, }: GoogleCalendarSyncDialogProps) => { const isMobile = useIsMobile() const [isBusy, setIsBusy] = useState(false) @@ -72,30 +103,38 @@ export const GoogleCalendarSyncDialog = ({ setTimeout(() => setCopied(false), 2000) } - const handleSyncNow = async () => { - if (isBusy) return + /** + * Shared busy/error envelope: every card action maps a failed result to the + * same `actionError` line and never leaves the buttons stuck in busy. + */ + const runAction = async ( + run: () => Promise, + fallbackMessage: string, + ): Promise => { + if (isBusy) return null setIsBusy(true) setActionError(null) try { - const result = await onSyncNow() - if (!result.ok) setActionError(result.message ?? 'Não foi possível sincronizar.') + const result = await run() + if (!result.ok) setActionError(result.message ?? fallbackMessage) + return result } finally { setIsBusy(false) } } - const handleSetDisabled = async (disabled: boolean) => { - if (isBusy) return - setIsBusy(true) - setActionError(null) - try { - const result = await onSetDisabled(disabled) - if (!result.ok) setActionError(result.message ?? 'Não foi possível atualizar.') - } finally { - setIsBusy(false) - } + const handleSyncNow = () => runAction(onSyncNow, 'Não foi possível sincronizar.') + + const handleSetDisabled = (disabled: boolean) => + runAction(() => onSetDisabled(disabled), 'Não foi possível atualizar.') + + const handleStartOAuth = async () => { + const result = await runAction(onStartOAuth, 'Não foi possível iniciar a conexão.') + if (result?.ok) window.location.assign(result.authorizeUrl) } + const handleDisconnect = () => runAction(onDisconnect, 'Não foi possível desconectar.') + const linkBlock = state.addLink ? (