From 95b8e69bb41542befc39da2da0afe14013b854d9 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Mon, 27 Jul 2026 12:46:20 -0300 Subject: [PATCH 01/14] feat: enhance tests for useJobsData and auth service with new scenarios --- .../unit/hooks/useJobsData.extended.test.tsx | 51 +++++++++++++++++++ .../new_dashboard/branch-coverage.test.tsx | 22 ++++++++ .../unit/new_dashboard/theme.toast.test.tsx | 6 +++ .../tests/unit/services/auth.service.test.tsx | 44 ++++++++++++++++ 4 files changed, 123 insertions(+) diff --git a/frontend/tests/unit/hooks/useJobsData.extended.test.tsx b/frontend/tests/unit/hooks/useJobsData.extended.test.tsx index 44c5ba8..8ec2036 100644 --- a/frontend/tests/unit/hooks/useJobsData.extended.test.tsx +++ b/frontend/tests/unit/hooks/useJobsData.extended.test.tsx @@ -67,6 +67,38 @@ describe("useJobsData extended", () => { expect(result.current.error).toBe("falha jobs"); }); + it("aplica os metadados padrão quando a API retorna payload parcial", async () => { + mocks.fetchJobsByAPIMock.mockResolvedValueOnce({}); + + const { result } = renderHook(() => useJobsData(3, 10)); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.meta).toEqual({ + total: 0, + hasNext: false, + hasPrev: false, + page: 3, + limit: 10, + totalPages: 0, + }); + }); + + expect(result.current.jobs).toEqual([]); + }); + + it("usa mensagem padrão quando carregar vagas rejeita sem Error", async () => { + mocks.fetchJobsByAPIMock.mockRejectedValueOnce("offline"); + + const { result } = renderHook(() => useJobsData()); + + await waitFor(() => { + expect(result.current.error).toBe( + "Erro inesperado ao carregar vagas.", + ); + }); + }); + it("triggerScraper carrega jobs após executar request", async () => { const { result } = renderHook(() => useJobsData()); @@ -78,4 +110,23 @@ describe("useJobsData extended", () => { expect(mocks.fetchJobsByAPIMock).toHaveBeenCalled(); expect(result.current.scraping).toBe(false); }); + + it("usa mensagem padrão quando o scraper rejeita sem Error", async () => { + mocks.runScraperRequestMock.mockRejectedValueOnce("indisponível"); + + const { result } = renderHook(() => useJobsData()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.triggerScraper(); + }); + + expect(result.current.error).toBe( + "Erro inesperado ao executar o scraper.", + ); + expect(result.current.scraping).toBe(false); + }); }); diff --git a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx index 8af7f13..765865e 100644 --- a/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx +++ b/frontend/tests/unit/new_dashboard/branch-coverage.test.tsx @@ -6,6 +6,7 @@ import { Header } from "@/domains/new_dashboard/components/layout/Header"; import { MessageDetailModal } from "@/domains/new_dashboard/components/layout/MessageDetailModal"; import { JobDetailModal } from "@/domains/new_dashboard/components/jobs/JobDetailModal"; import { JobRow } from "@/domains/new_dashboard/components/jobs/JobRow"; +import { MentoringTab } from "@/domains/new_dashboard/components/mentoring/MentoringTab"; import { ProfileForm } from "@/domains/new_dashboard/components/profile/ProfileForm"; import { Modal } from "@/domains/new_dashboard/components/shared/Modal"; import { @@ -236,6 +237,27 @@ describe("new_dashboard branch coverage", () => { expect(onClose).toHaveBeenCalledOnce(); }); + it("abre, confirma e fecha os detalhes de uma mentoria", () => { + render(); + + fireEvent.click(screen.getAllByRole("button", { name: "Ver Mais" })[0]); + + expect( + screen.getByRole("heading", { name: "Julio Silva", level: 2 }), + ).toBeInTheDocument(); + expect(screen.queryByText(/participação confirmada/i)).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole("button", { name: /confirmar participação/i }), + ); + expect(screen.getByText(/participação confirmada/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Fechar" })); + expect( + screen.queryByRole("heading", { name: "Julio Silva", level: 2 }), + ).not.toBeInTheDocument(); + }); + it("usa origem sistema como fallback no detalhe da mensagem", () => { render( { expect(container).toBeEmptyDOMElement(); }); + it("renderiza toast quando recebe uma mensagem", () => { + render(); + + expect(screen.getByText("Perfil atualizado")).toBeInTheDocument(); + }); + it("lança erro quando o contexto de toast é usado fora do provider", () => { expect(() => render()).toThrow( /useToastContext must be used within ToastProvider/i, diff --git a/frontend/tests/unit/services/auth.service.test.tsx b/frontend/tests/unit/services/auth.service.test.tsx index 96c888d..ae0773a 100644 --- a/frontend/tests/unit/services/auth.service.test.tsx +++ b/frontend/tests/unit/services/auth.service.test.tsx @@ -33,6 +33,50 @@ describe("authService", () => { beforeEach(() => { fetchMock.mockReset(); vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + describe("response parsing and URL normalization", () => { + it("uses the configured API URL without trailing slashes", async () => { + vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com///"); + fetchMock.mockResolvedValueOnce(mockResponse({ jsonData: { ok: true } })); + + await expect(auth.logout()).resolves.toEqual({ ok: true }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/auth/logout", + expect.objectContaining({ method: "POST" }), + ); + }); + + it("parses non-JSON response bodies as messages", async () => { + fetchMock.mockResolvedValueOnce( + mockResponse({ + contentType: "text/plain", + textData: "Logout concluído", + }), + ); + + await expect(auth.logout()).resolves.toEqual({ + message: "Logout concluído", + }); + }); + + it("returns an empty payload for an empty response body", async () => { + fetchMock.mockResolvedValueOnce( + mockResponse({ contentType: "", textData: "" }), + ); + + await expect(auth.logout()).resolves.toEqual({}); + }); + + it("returns an empty payload when response parsing fails", async () => { + const response = mockResponse({ contentType: "application/json" }); + response.json.mockRejectedValueOnce(new SyntaxError("invalid JSON")); + fetchMock.mockResolvedValueOnce(response); + + await expect(auth.logout()).resolves.toEqual({}); + }); }); describe("login", () => { From eb34cd7db6c7fed027ee9efa06fee42959ec171a Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 12:46:54 -0300 Subject: [PATCH 02/14] =?UTF-8?q?docs(PAV-7):=20spec=20de=20vincular=20con?= =?UTF-8?q?ta=20social=20e=20gerenciar=20conex=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-07-27-pav-7-gerenciar-conexoes-design.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-27-pav-7-gerenciar-conexoes-design.md diff --git a/docs/superpowers/specs/2026-07-27-pav-7-gerenciar-conexoes-design.md b/docs/superpowers/specs/2026-07-27-pav-7-gerenciar-conexoes-design.md new file mode 100644 index 0000000..608c8d1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-pav-7-gerenciar-conexoes-design.md @@ -0,0 +1,126 @@ +# PAV-7 — Vincular conta social a usuário local + Gerenciar conexões + +**Ticket:** [PAV-7](https://linear.app/tatame/issue/PAV-7) · EPIC 1 — Autenticação + Login Social +**Branch:** `feature/pav-7-gerenciar-conexoes` +**Data:** 2026-07-27 + +## Contexto + +Boa parte do backend já existe: + +- A tabela `accounts` (`src/db/schema/accounts.ts`) já é o modelo de providers pedido pelo ticket: + `userId`, `provider`, `providerAccountId`, tokens, com `uniqueIndex(provider, providerAccountId)`. +- `findOrCreateUser` (`src/modules/users/functions/findOrCreateUser.ts`) já faz o **merge** no login: + busca por provider → senão por email (vincula ao usuário existente sem duplicar) → senão cria usuário + account. + Isso já satisfaz o critério "não cria duplicidade quando o email já existe". +- Login OAuth (`google`, `linkedin`, `github`) e login local (email/senha via `credentials`) funcionam. +- Sessão via `req.session` (`userId`, `role`); OAuth guarda `oauth_state` na sessão e valida no callback. + +**Falta** para fechar o PAV-7 (escopo desta branch — os 4 itens): + +1. Conectar um provider estando **já logado** (hoje o OAuth só faz login). +2. Desconectar um provider, com validação para não trancar o usuário para fora. +3. Listar as conexões do usuário atual. +4. Tela "Gerenciar conexões" no frontend. + +## Decisões de produto + +- **Conectar logado (modo vínculo):** reusar o fluxo OAuth atual com uma flag de intenção (`intent=link`), + em vez de endpoints/redirect URIs novos. Motivo: não exige cadastrar novas redirect URIs nos consoles + OAuth (Google/LinkedIn/GitHub), reaproveitando o `state`/CSRF e o código de provider já existentes. +- **Regra de desconexão:** manter **≥ 1 método de login**. Conta como método: qualquer provider vinculado + **ou** senha (`credentials`). Só bloqueia remover o **último** método. Usuário pode ser só-OAuth. +- **Conflito ao conectar:** se a conta do provider já estiver vinculada a **outro** usuário, **bloquear com erro** + (`provider_already_linked`). Não migra nem faz merge silencioso. + +## Modelo de dados + +Sem mudança de schema. Nenhuma migration nova. `accounts` e `credentials` já cobrem o necessário. + +## Backend + +### Service + +Novos métodos (reusando `findUserByProvider`, `createAccount` e a leitura de `credentials`): + +- **`linkProvider(userId, provider, profile)`** + - Busca `account` por `(provider, providerAccountId = profile.id)`. + - Já vinculado ao **próprio** `userId` → sucesso idempotente (no-op). + - Vinculado a **outro** usuário → lança `provider_already_linked`. + - Senão → `createAccount({ userId, provider, profile })`. +- **`listConnections(userId)`** + - Retorna, para cada provider suportado (`google`, `linkedin`, `github`): + `{ provider, connected: boolean, connectedAt?: Date }`. + - Mais `hasPassword: boolean` (existe linha em `credentials` para o usuário). +- **`disconnectProvider(userId, provider)`** + - `methodsAfter = (providers vinculados distintos, exceto o removido) + (hasPassword ? 1 : 0)`. + - Se `methodsAfter === 0` → lança `cannot_remove_last_method`. + - Senão → deleta a(s) linha(s) de `accounts` de `(userId, provider)`. + +### Rotas (`src/routes/auth.routes.ts`) + +- **Conectar:** reusa `GET /auth/:provider/url`, aceitando query `?intent=link`. + No `AuthController.getUrl`, quando `intent=link`, guarda `oauth_intent="link"` na sessão + (ao lado de `oauth_state`). +- **Callback:** em `AuthController.callback`, após validar o `state` (lógica atual inalterada): + - Se `oauth_intent === "link"` **e** `req.session.userId` existe: + - Obtém o profile via provider (`getProfileFromProvider`). + - Chama `linkProvider(req.session.userId, provider, profile)`. + - **Não** altera a sessão. + - Redireciona para `${FRONTEND_URL}/perfil/conexoes?linked=` + (ou `?error=` em falha). + - Senão → fluxo de login atual (cria/atualiza sessão, redireciona para `/auth/callback`). + - Sempre limpa `oauth_intent` ao final (sucesso ou erro). +- **Listar:** `GET /auth/connections` (com `requireAuth`) → `listConnections(req.session.userId)`. +- **Desconectar:** `DELETE /auth/connections/:provider` (com `requireAuth`) → `disconnectProvider(...)`. + +As rotas `/connections` e `/connections/:provider` não colidem com `/:provider/url` e +`/:provider/callback` (segmentos e/ou métodos distintos). + +### Tratamento de erros + +Códigos mapeados no `errorHandler` para HTTP: + +- `provider_already_linked` → 409 +- `cannot_remove_last_method` → 409 + +No modo link, falhas no callback viram redirect `?error=` (o frontend traduz para PT-BR). + +## Frontend + +- **Rota:** `/perfil/conexoes`, dentro do `ProtectedRoute` (`src/app/AppRoutes.tsx`). +- **API client** `connectionsApi.ts` (segue o padrão de `authApi.ts` / `getBaseUrl`/`buildUrl`): + - `getConnections()` → `GET /auth/connections`. + - `disconnect(provider)` → `DELETE /auth/connections/:provider`. + - `connect(provider)` → chama `GET /auth/:provider/url?intent=link` e faz `window.location = url`. +- **UI:** lista os 3 providers (`google`, `linkedin`, `github`) com estado **Conectado / Não conectado**: + - **Conectar** (não conectado) → inicia OAuth em modo vínculo. + - **Desconectar** (conectado) → confirmação, depois `disconnect`. + - Botão Desconectar **desabilitado** quando for o último método (front calcula por `connections` + `hasPassword`; + o backend revalida como fonte da verdade). + - Ao retornar do OAuth, lê `?linked=` / `?error=` da URL e exibe toast de sucesso/erro. + +## Testes + +- **Unit (service):** + - `linkProvider`: idempotente (mesmo usuário), conflito (`provider_already_linked`), vínculo novo. + - `disconnectProvider`: bloqueia último método (`cannot_remove_last_method`), remove quando há outro método. + - `listConnections`: estados conectado/não conectado e `hasPassword`. +- **Integration (rotas):** + - `GET /auth/connections` (autenticado vs 401). + - `DELETE /auth/connections/:provider` (200 e 409 no último método). + - Callback com `intent=link`: vincula sem trocar a sessão; conflito redireciona com `?error=`. +- Seguir os padrões de `tests/unit/modules/auth` e `tests/integration/routes`. + +## Fora de escopo (YAGNI) + +- Re-autenticação antes de desconectar. +- Exibir email/detalhes da conta vinculada na tela. +- Novas migrations ou colunas. +- Alterar o comportamento de merge-por-email do login atual. + +## Critérios de aceite (do ticket) + +- Um usuário pode ter Google + LinkedIn vinculados. ✔ via `linkProvider` + tela. +- Não cria duplicidade quando o email já existe. ✔ já garantido por `findOrCreateUser`; reforçado por + `provider_already_linked` no modo vínculo. From 8cd92b9d75ed57f6321223abee95f47bb8c5d02a Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 12:51:29 -0300 Subject: [PATCH 03/14] =?UTF-8?q?docs(PAV-7):=20plano=20de=20implementa?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20gerenciar=20conex=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-27-pav-7-gerenciar-conexoes.md | 1103 +++++++++++++++++ 1 file changed, 1103 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-pav-7-gerenciar-conexoes.md diff --git a/docs/superpowers/plans/2026-07-27-pav-7-gerenciar-conexoes.md b/docs/superpowers/plans/2026-07-27-pav-7-gerenciar-conexoes.md new file mode 100644 index 0000000..baf2d19 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-pav-7-gerenciar-conexoes.md @@ -0,0 +1,1103 @@ +# PAV-7 — Gerenciar Conexões Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Permitir que um usuário logado conecte/desconecte múltiplos providers OAuth (Google/LinkedIn/GitHub) à sua conta, com tela de gestão e validações. + +**Architecture:** Reusa o fluxo OAuth existente com uma flag `intent=link` na sessão (Abordagem A do spec) para vincular providers sem novos redirect URIs. Novas funções puras na camada `users/functions` fazem link/disconnect/list; um `ConnectionsController` expõe list/disconnect; o `AuthController` ganha um branch de "modo vínculo" no callback. Frontend ganha uma `ConnectionsPage` em `/perfil/conexoes`. + +**Tech Stack:** Node + Express + Drizzle ORM (Postgres), Vitest + Supertest, React + React Router + Vite. + +## Global Constraints + +- Sem novas migrations / mudança de schema. `accounts` e `credentials` já existem. +- Providers suportados (verbatim): `["google", "linkedin", "github"]`. +- Regra de desconexão: manter **≥ 1 método de login** (provider vinculado OU senha em `credentials`). +- Conflito ao vincular provider já usado por outro usuário → `AppError.conflict` (HTTP 409). +- Erros de domínio usam `AppError` (`src/lib/errors.ts`); `errorHandler` já mapeia `AppError` → status. +- Testes: `vitest run` (backend). Mocks via `vi.hoisted` seguindo `tests/unit/modules/auth/providers.test.ts`. +- Commits frequentes, um por task. + +**Spec:** `docs/superpowers/specs/2026-07-27-pav-7-gerenciar-conexoes-design.md` + +--- + +## File Structure + +**Backend (criar):** +- `src/modules/users/functions/linkProviderToUser.ts` — vincula provider ao usuário, com checagem de conflito. +- `src/modules/users/functions/disconnectProvider.ts` — remove provider, valida último método. +- `src/modules/users/functions/listUserConnections.ts` — lista status dos providers + `hasPassword`; exporta `SUPPORTED_PROVIDERS`. +- `src/modules/auth/connections.controller.ts` — `ConnectionsController` (list, disconnect). + +**Backend (modificar):** +- `src/modules/auth/auth.controller.ts` — `getUrl` grava `oauth_intent`; `callback` ganha branch de vínculo. +- `src/routes/auth.routes.ts` — rotas `GET /auth/connections`, `DELETE /auth/connections/:provider`. + +**Frontend (criar):** +- `src/domains/auth/infrastructure/connectionsApi.ts` — client HTTP (get/disconnect/connect). +- `src/domains/auth/presentation/pages/ConnectionsPage.tsx` — tela "Gerenciar conexões". + +**Frontend (modificar):** +- `src/app/AppRoutes.tsx` — rota `/perfil/conexoes`. + +--- + +## Task 1: `linkProviderToUser` (vincular com checagem de conflito) + +**Files:** +- Create: `backend/src/modules/users/functions/linkProviderToUser.ts` +- Test: `backend/tests/unit/modules/users/linkProviderToUser.test.ts` + +**Interfaces:** +- Consumes: `createAccount({ userId, provider, profile }, tx)` de `./createAccount`; `AppError` de `src/lib/errors`. +- Produces: `linkProviderToUser({ userId: string, provider: string, profile: OAuthProfile }, tx?: DB): Promise` — no-op se já vinculado ao mesmo usuário; lança `AppError.conflict` se vinculado a outro; senão cria account. + +- [ ] **Step 1: Write the failing test** + +```ts +// backend/tests/unit/modules/users/linkProviderToUser.test.ts +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindFirst: vi.fn(), + createAccount: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { query: { accounts: { findFirst: mocks.accountsFindFirst } } }, +})); + +vi.mock("../../../../src/modules/users/functions/createAccount.js", () => ({ + createAccount: mocks.createAccount, +})); + +import { linkProviderToUser } from "../../../../src/modules/users/functions/linkProviderToUser"; + +const profile = { id: "prov-123", email: "a@a.com" } as any; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("linkProviderToUser", () => { + it("cria account quando provider não está vinculado", async () => { + mocks.accountsFindFirst.mockResolvedValue(undefined); + await linkProviderToUser({ userId: "user-A", provider: "google", profile }); + expect(mocks.createAccount).toHaveBeenCalledWith( + { userId: "user-A", provider: "google", profile }, + expect.anything(), + ); + }); + + it("é idempotente quando já vinculado ao mesmo usuário", async () => { + mocks.accountsFindFirst.mockResolvedValue({ userId: "user-A" }); + await linkProviderToUser({ userId: "user-A", provider: "google", profile }); + expect(mocks.createAccount).not.toHaveBeenCalled(); + }); + + it("lança conflito quando vinculado a outro usuário", async () => { + mocks.accountsFindFirst.mockResolvedValue({ userId: "user-B" }); + await expect( + linkProviderToUser({ userId: "user-A", provider: "google", profile }), + ).rejects.toMatchObject({ code: "CONFLICT", statusCode: 409 }); + expect(mocks.createAccount).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run tests/unit/modules/users/linkProviderToUser.test.ts` +Expected: FAIL (module `linkProviderToUser` não existe). + +- [ ] **Step 3: Write minimal implementation** + +```ts +// backend/src/modules/users/functions/linkProviderToUser.ts +import { db } from "../../../db/client"; +import { DB } from "../../../db/types/types"; +import { AppError } from "../../../lib/errors"; +import { OAuthProfile } from "../../types/auth.types"; +import { createAccount } from "./createAccount"; + +type LinkProviderParams = { + userId: string; + provider: string; + profile: OAuthProfile; +}; + +export async function linkProviderToUser( + { userId, provider, profile }: LinkProviderParams, + tx: DB = db, +): Promise { + const existingAccount = await tx.query.accounts.findFirst({ + where: (acc, { eq, and }) => + and(eq(acc.provider, provider), eq(acc.providerAccountId, profile.id)), + }); + + if (existingAccount) { + if (existingAccount.userId === userId) return; + throw AppError.conflict("Essa conta já está vinculada a outro usuário."); + } + + await createAccount({ userId, provider, profile }, tx); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && npx vitest run tests/unit/modules/users/linkProviderToUser.test.ts` +Expected: PASS (3 testes). + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/modules/users/functions/linkProviderToUser.ts backend/tests/unit/modules/users/linkProviderToUser.test.ts +git commit -m "feat(PAV-7): vincular provider a usuário com checagem de conflito" +``` + +--- + +## Task 2: `disconnectProvider` (desconectar com validação de último método) + +**Files:** +- Create: `backend/src/modules/users/functions/disconnectProvider.ts` +- Test: `backend/tests/unit/modules/users/disconnectProvider.test.ts` + +**Interfaces:** +- Consumes: `db` (`accounts`, `credentials` queries), `accounts` schema, `AppError`, `and`/`eq` de `drizzle-orm`. +- Produces: `disconnectProvider({ userId: string, provider: string }, tx?: DB): Promise` — lança `AppError.conflict` se remover deixaria 0 métodos; senão deleta rows de `accounts` desse `(userId, provider)`. + +- [ ] **Step 1: Write the failing test** + +```ts +// backend/tests/unit/modules/users/disconnectProvider.test.ts +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindMany: vi.fn(), + credentialsFindFirst: vi.fn(), + deleteWhere: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { + query: { + accounts: { findMany: mocks.accountsFindMany }, + credentials: { findFirst: mocks.credentialsFindFirst }, + }, + delete: vi.fn(() => ({ where: mocks.deleteWhere })), + }, +})); + +vi.mock("../../../../src/db/schema/index.js", () => ({ accounts: {} })); + +import { disconnectProvider } from "../../../../src/modules/users/functions/disconnectProvider"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("disconnectProvider", () => { + it("desconecta quando há outro método (outro provider)", async () => { + mocks.accountsFindMany.mockResolvedValue([ + { provider: "google" }, + { provider: "github" }, + ]); + mocks.credentialsFindFirst.mockResolvedValue(undefined); + await disconnectProvider({ userId: "user-A", provider: "google" }); + expect(mocks.deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("desconecta quando há senha como fallback", async () => { + mocks.accountsFindMany.mockResolvedValue([{ provider: "google" }]); + mocks.credentialsFindFirst.mockResolvedValue({ userId: "user-A" }); + await disconnectProvider({ userId: "user-A", provider: "google" }); + expect(mocks.deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("bloqueia quando é o último método", async () => { + mocks.accountsFindMany.mockResolvedValue([{ provider: "google" }]); + mocks.credentialsFindFirst.mockResolvedValue(undefined); + await expect( + disconnectProvider({ userId: "user-A", provider: "google" }), + ).rejects.toMatchObject({ code: "CONFLICT", statusCode: 409 }); + expect(mocks.deleteWhere).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run tests/unit/modules/users/disconnectProvider.test.ts` +Expected: FAIL (módulo não existe). + +- [ ] **Step 3: Write minimal implementation** + +```ts +// backend/src/modules/users/functions/disconnectProvider.ts +import { and, eq } from "drizzle-orm"; +import { db } from "../../../db/client"; +import { accounts } from "../../../db/schema"; +import { DB } from "../../../db/types/types"; +import { AppError } from "../../../lib/errors"; + +type DisconnectParams = { userId: string; provider: string }; + +export async function disconnectProvider( + { userId, provider }: DisconnectParams, + tx: DB = db, +): Promise { + const userAccounts = await tx.query.accounts.findMany({ + where: (acc, { eq }) => eq(acc.userId, userId), + }); + + const hasPassword = Boolean( + await tx.query.credentials.findFirst({ + where: (c, { eq }) => eq(c.userId, userId), + }), + ); + + const remainingProviders = new Set( + userAccounts.map((a) => a.provider).filter((p) => p !== provider), + ); + + const methodsAfter = remainingProviders.size + (hasPassword ? 1 : 0); + + if (methodsAfter === 0) { + throw AppError.conflict( + "Não é possível desconectar seu último método de login.", + ); + } + + await tx + .delete(accounts) + .where(and(eq(accounts.userId, userId), eq(accounts.provider, provider))); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && npx vitest run tests/unit/modules/users/disconnectProvider.test.ts` +Expected: PASS (3 testes). + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/modules/users/functions/disconnectProvider.ts backend/tests/unit/modules/users/disconnectProvider.test.ts +git commit -m "feat(PAV-7): desconectar provider validando último método de login" +``` + +--- + +## Task 3: `listUserConnections` (status dos providers + hasPassword) + +**Files:** +- Create: `backend/src/modules/users/functions/listUserConnections.ts` +- Test: `backend/tests/unit/modules/users/listUserConnections.test.ts` + +**Interfaces:** +- Consumes: `db` (`accounts`, `credentials` queries). +- Produces: + - `SUPPORTED_PROVIDERS = ["google", "linkedin", "github"] as const` + - `type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]` + - `type ConnectionStatus = { provider: SupportedProvider; connected: boolean; connectedAt: Date | null }` + - `type UserConnections = { hasPassword: boolean; connections: ConnectionStatus[] }` + - `listUserConnections(userId: string, tx?: DB): Promise` + +- [ ] **Step 1: Write the failing test** + +```ts +// backend/tests/unit/modules/users/listUserConnections.test.ts +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindMany: vi.fn(), + credentialsFindFirst: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { + query: { + accounts: { findMany: mocks.accountsFindMany }, + credentials: { findFirst: mocks.credentialsFindFirst }, + }, + }, +})); + +import { listUserConnections } from "../../../../src/modules/users/functions/listUserConnections"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listUserConnections", () => { + it("marca conectados e retorna hasPassword", async () => { + const createdAt = new Date("2026-01-01T00:00:00Z"); + mocks.accountsFindMany.mockResolvedValue([ + { provider: "google", createdAt }, + ]); + mocks.credentialsFindFirst.mockResolvedValue({ userId: "user-A" }); + + const result = await listUserConnections("user-A"); + + expect(result.hasPassword).toBe(true); + expect(result.connections).toEqual([ + { provider: "google", connected: true, connectedAt: createdAt }, + { provider: "linkedin", connected: false, connectedAt: null }, + { provider: "github", connected: false, connectedAt: null }, + ]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run tests/unit/modules/users/listUserConnections.test.ts` +Expected: FAIL (módulo não existe). + +- [ ] **Step 3: Write minimal implementation** + +```ts +// backend/src/modules/users/functions/listUserConnections.ts +import { db } from "../../../db/client"; +import { DB } from "../../../db/types/types"; + +export const SUPPORTED_PROVIDERS = ["google", "linkedin", "github"] as const; +export type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]; + +export type ConnectionStatus = { + provider: SupportedProvider; + connected: boolean; + connectedAt: Date | null; +}; + +export type UserConnections = { + hasPassword: boolean; + connections: ConnectionStatus[]; +}; + +export async function listUserConnections( + userId: string, + tx: DB = db, +): Promise { + const userAccounts = await tx.query.accounts.findMany({ + where: (acc, { eq }) => eq(acc.userId, userId), + }); + + const hasPassword = Boolean( + await tx.query.credentials.findFirst({ + where: (c, { eq }) => eq(c.userId, userId), + }), + ); + + const connections: ConnectionStatus[] = SUPPORTED_PROVIDERS.map((provider) => { + const account = userAccounts.find((a) => a.provider === provider); + return { + provider, + connected: Boolean(account), + connectedAt: account?.createdAt ?? null, + }; + }); + + return { hasPassword, connections }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && npx vitest run tests/unit/modules/users/listUserConnections.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/modules/users/functions/listUserConnections.ts backend/tests/unit/modules/users/listUserConnections.test.ts +git commit -m "feat(PAV-7): listar conexões do usuário com status por provider" +``` + +--- + +## Task 4: `ConnectionsController` + rotas `GET/DELETE /auth/connections` + +**Files:** +- Create: `backend/src/modules/auth/connections.controller.ts` +- Modify: `backend/src/routes/auth.routes.ts` +- Test: `backend/tests/integration/routes/connections.routes.test.ts` + +**Interfaces:** +- Consumes: `listUserConnections`, `disconnectProvider`, `SUPPORTED_PROVIDERS`, `requireAuth`, `AppError`. +- Produces: + - `class ConnectionsController { list(req, res): Promise; disconnect(req, res): Promise }` + - Rotas: `GET /auth/connections` (requireAuth) → `{ hasPassword, connections }`; `DELETE /auth/connections/:provider` (requireAuth) → `{ ok: true }`. + +- [ ] **Step 1: Write the failing test** + +```ts +// backend/tests/integration/routes/connections.routes.test.ts +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppError } from "../../../src/lib/errors"; + +const mocks = vi.hoisted(() => ({ + listUserConnections: vi.fn(), + disconnectProvider: vi.fn(), +})); + +vi.mock("../../../src/modules/users/functions/listUserConnections", () => ({ + listUserConnections: mocks.listUserConnections, + SUPPORTED_PROVIDERS: ["google", "linkedin", "github"], +})); + +vi.mock("../../../src/modules/users/functions/disconnectProvider", () => ({ + disconnectProvider: mocks.disconnectProvider, +})); + +// Sessão fake: injeta userId +vi.mock("../../../src/middleware/requireAuth", () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.session = { userId: "user-A" }; + next(); + }, +})); + +import { errorHandler } from "../../../src/middleware/errorHandler"; +import { ConnectionsController } from "../../../src/modules/auth/connections.controller"; +import { requireAuth } from "../../../src/middleware/requireAuth"; + +function buildApp() { + const app = express(); + app.use(express.json()); + const controller = new ConnectionsController(); + app.get("/auth/connections", requireAuth, (req, res, next) => + controller.list(req, res).catch(next), + ); + app.delete("/auth/connections/:provider", requireAuth, (req, res, next) => + controller.disconnect(req, res).catch(next), + ); + app.use(errorHandler); + return app; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("connections routes", () => { + it("GET /auth/connections retorna status", async () => { + mocks.listUserConnections.mockResolvedValue({ + hasPassword: false, + connections: [{ provider: "google", connected: true, connectedAt: null }], + }); + const res = await request(buildApp()).get("/auth/connections"); + expect(res.status).toBe(200); + expect(res.body.connections[0].provider).toBe("google"); + }); + + it("DELETE /auth/connections/:provider retorna 200", async () => { + mocks.disconnectProvider.mockResolvedValue(undefined); + const res = await request(buildApp()).delete("/auth/connections/google"); + expect(res.status).toBe(200); + expect(mocks.disconnectProvider).toHaveBeenCalledWith({ + userId: "user-A", + provider: "google", + }); + }); + + it("DELETE responde 409 no último método", async () => { + mocks.disconnectProvider.mockRejectedValue( + AppError.conflict("Não é possível desconectar seu último método de login."), + ); + const res = await request(buildApp()).delete("/auth/connections/google"); + expect(res.status).toBe(409); + expect(res.body.code).toBe("CONFLICT"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run tests/integration/routes/connections.routes.test.ts` +Expected: FAIL (`ConnectionsController` não existe). + +- [ ] **Step 3: Write the controller** + +```ts +// backend/src/modules/auth/connections.controller.ts +import { Request, Response } from "express"; +import { AppError } from "../../lib/errors"; +import { disconnectProvider } from "../users/functions/disconnectProvider"; +import { + listUserConnections, + SUPPORTED_PROVIDERS, +} from "../users/functions/listUserConnections"; + +export class ConnectionsController { + async list(req: Request, res: Response) { + const userId = req.session.userId as string; + const result = await listUserConnections(userId); + return res.json(result); + } + + async disconnect(req: Request, res: Response) { + const userId = req.session.userId as string; + const provider = req.params.provider; + + if (!(SUPPORTED_PROVIDERS as readonly string[]).includes(provider)) { + throw AppError.validation("Provider inválido."); + } + + await disconnectProvider({ userId, provider }); + return res.json({ ok: true }); + } +} +``` + +- [ ] **Step 4: Wire the routes** + +Em `backend/src/routes/auth.routes.ts`, adicione o import e as rotas logo após o bloco `// OAuth` (antes de `// Credentials`): + +```ts +import { requireAuth } from "../middleware/requireAuth"; +import { ConnectionsController } from "../modules/auth/connections.controller"; + +const connectionsController = new ConnectionsController(); + +// Connections (usuário logado) +router.get("/connections", requireAuth, (req, res, next) => { + connectionsController.list(req, res).catch(next); +}); +router.delete("/connections/:provider", requireAuth, (req, res, next) => { + connectionsController.disconnect(req, res).catch(next); +}); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && npx vitest run tests/integration/routes/connections.routes.test.ts` +Expected: PASS (3 testes). + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/modules/auth/connections.controller.ts backend/src/routes/auth.routes.ts backend/tests/integration/routes/connections.routes.test.ts +git commit -m "feat(PAV-7): endpoints GET/DELETE de conexões do usuário" +``` + +--- + +## Task 5: Modo vínculo no `AuthController` (getUrl intent + callback branch) + +**Files:** +- Modify: `backend/src/modules/auth/auth.controller.ts` +- Test: `backend/tests/unit/modules/auth/auth.controller.link.test.ts` + +**Interfaces:** +- Consumes: `linkProviderToUser` (Task 1), `AuthService.getProfileFromProvider`, `AppError`. +- Produces: `getUrl` grava `oauth_intent="link"` na sessão quando `?intent=link` e há `userId`; `callback` vincula e redireciona para `${FRONTEND_URL}/perfil/conexoes?linked=` (sucesso) ou `?error=provider_already_linked|link_failed` (falha), sem trocar a sessão. + +- [ ] **Step 1: Write the failing test** + +```ts +// backend/tests/unit/modules/auth/auth.controller.link.test.ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppError } from "../../../../src/lib/errors"; + +const mocks = vi.hoisted(() => ({ + linkProviderToUser: vi.fn(), + getProfileFromProvider: vi.fn(), + handleCallback: vi.fn(), +})); + +vi.mock("../../../../src/modules/users/functions/linkProviderToUser", () => ({ + linkProviderToUser: mocks.linkProviderToUser, +})); + +import { AuthController } from "../../../../src/modules/auth/auth.controller"; + +function fakeRes() { + return { + redirect: vi.fn(), + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } as any; +} + +function fakeService() { + return { + getProfileFromProvider: mocks.getProfileFromProvider, + handleCallback: mocks.handleCallback, + getAuthUrl: vi.fn(), + } as any; +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.FRONTEND_URL = "http://localhost:5173"; + process.env.APP_URL = "http://localhost:3001"; +}); + +describe("AuthController callback — modo vínculo", () => { + it("vincula e redireciona para /perfil/conexoes?linked= sem trocar sessão", async () => { + mocks.getProfileFromProvider.mockResolvedValue({ id: "prov-1" }); + mocks.linkProviderToUser.mockResolvedValue(undefined); + + const controller = new AuthController(fakeService()); + const save = vi.fn(); + const req = { + params: { provider: "google" }, + query: { code: "c", state: "s" }, + session: { + userId: "user-A", + role: "user", + oauth_state: "s", + oauth_intent: "link", + save, + }, + } as any; + const res = fakeRes(); + + await controller.callback(req, res); + + expect(mocks.linkProviderToUser).toHaveBeenCalledWith({ + userId: "user-A", + provider: "google", + profile: { id: "prov-1" }, + }); + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/perfil/conexoes?linked=google", + ); + expect(mocks.handleCallback).not.toHaveBeenCalled(); + }); + + it("redireciona com erro quando o provider já está vinculado", async () => { + mocks.getProfileFromProvider.mockResolvedValue({ id: "prov-1" }); + mocks.linkProviderToUser.mockRejectedValue( + AppError.conflict("já vinculado"), + ); + + const controller = new AuthController(fakeService()); + const req = { + params: { provider: "google" }, + query: { code: "c", state: "s" }, + session: { + userId: "user-A", + role: "user", + oauth_state: "s", + oauth_intent: "link", + save: vi.fn(), + }, + } as any; + const res = fakeRes(); + + await controller.callback(req, res); + + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/perfil/conexoes?error=provider_already_linked", + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && npx vitest run tests/unit/modules/auth/auth.controller.link.test.ts` +Expected: FAIL (callback ainda não faz branch de vínculo). + +- [ ] **Step 3: Update `getUrl` para gravar a intenção** + +Em `backend/src/modules/auth/auth.controller.ts`, substitua o corpo de `getUrl` por: + +```ts + async getUrl(req: Request, res: Response) { + const provider = req.params.provider as OAuthProvider; + const state = randomBytes(16).toString("hex"); + + (req.session as { oauth_state?: string }).oauth_state = state; + + if (req.query.intent === "link" && req.session.userId) { + (req.session as { oauth_intent?: string }).oauth_intent = "link"; + } else { + delete (req.session as { oauth_intent?: string }).oauth_intent; + } + + await req.session.save(); + + const url = await this.authService.getAuthUrl(provider, state); + return res.json({ url }); + } +``` + +- [ ] **Step 4: Add the link branch in `callback`** + +No `callback`, logo após a linha `delete (req.session as { oauth_state?: string }).oauth_state;` e ANTES de `const result = await this.authService.handleCallback(...)`, insira: + +```ts + const intent = (req.session as { oauth_intent?: string }).oauth_intent; + delete (req.session as { oauth_intent?: string }).oauth_intent; + + if (intent === "link" && req.session.userId) { + try { + const profile = await this.authService.getProfileFromProvider({ + ...params, + callbackUrl, + }); + await linkProviderToUser({ + userId: req.session.userId, + provider: params.provider, + profile, + }); + await req.session.save(); + return res.redirect( + `${frontendUrl}/perfil/conexoes?linked=${params.provider}`, + ); + } catch (linkError) { + const code = + linkError instanceof AppError + ? "provider_already_linked" + : "link_failed"; + return res.redirect(`${frontendUrl}/perfil/conexoes?error=${code}`); + } + } +``` + +Adicione os imports no topo do arquivo: + +```ts +import { AppError } from "../../lib/errors.js"; +import { linkProviderToUser } from "../users/functions/linkProviderToUser.js"; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && npx vitest run tests/unit/modules/auth/auth.controller.link.test.ts` +Expected: PASS (2 testes). + +- [ ] **Step 6: Run the full auth suite (regressão)** + +Run: `cd backend && npx vitest run tests/unit/modules/auth tests/integration/routes/auth.routes.test.ts` +Expected: PASS (o fluxo de login existente continua funcionando). + +- [ ] **Step 7: Commit** + +```bash +git add backend/src/modules/auth/auth.controller.ts backend/tests/unit/modules/auth/auth.controller.link.test.ts +git commit -m "feat(PAV-7): modo vínculo no callback OAuth (conectar provider logado)" +``` + +--- + +## Task 6: Frontend — `connectionsApi.ts` + +**Files:** +- Create: `frontend/src/domains/auth/infrastructure/connectionsApi.ts` + +**Interfaces:** +- Produces: + - `type SupportedProvider = "google" | "linkedin" | "github"` + - `type ConnectionStatus = { provider: SupportedProvider; connected: boolean; connectedAt: string | null }` + - `type UserConnections = { hasPassword: boolean; connections: ConnectionStatus[] }` + - `getConnections(): Promise` + - `disconnectProvider(provider: SupportedProvider): Promise` + - `connectProvider(provider: SupportedProvider): Promise` (redireciona o browser) + +- [ ] **Step 1: Write the module** + +Segue o padrão de `frontend/src/domains/auth/infrastructure/authApi.ts` (base via `VITE_API_BASE_URL`, `credentials: "include"`). + +```ts +// frontend/src/domains/auth/infrastructure/connectionsApi.ts +export type SupportedProvider = "google" | "linkedin" | "github"; + +export type ConnectionStatus = { + provider: SupportedProvider; + connected: boolean; + connectedAt: string | null; +}; + +export type UserConnections = { + hasPassword: boolean; + connections: ConnectionStatus[]; +}; + +function getBaseUrl(): string { + const base = import.meta.env.VITE_API_BASE_URL; + if (base && base.trim().length > 0) return base.replace(/\/+$/, ""); + return ""; +} + +function buildUrl(path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + const base = getBaseUrl(); + return base ? `${base}${normalized}` : normalized; +} + +export async function getConnections(): Promise { + const response = await fetch(buildUrl("/auth/connections"), { + credentials: "include", + }); + if (!response.ok) throw new Error("Falha ao carregar conexões."); + return (await response.json()) as UserConnections; +} + +export async function disconnectProvider( + provider: SupportedProvider, +): Promise { + const response = await fetch(buildUrl(`/auth/connections/${provider}`), { + method: "DELETE", + credentials: "include", + }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { + message?: string; + }; + throw new Error(payload.message ?? "Falha ao desconectar."); + } +} + +export async function connectProvider( + provider: SupportedProvider, +): Promise { + const response = await fetch( + buildUrl(`/auth/${provider}/url?intent=link`), + { credentials: "include" }, + ); + if (!response.ok) throw new Error("Falha ao iniciar conexão."); + const { url } = (await response.json()) as { url: string }; + window.location.href = url; +} +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd frontend && npx tsc --noEmit` +Expected: sem erros novos no arquivo criado. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/domains/auth/infrastructure/connectionsApi.ts +git commit -m "feat(PAV-7): client de API de conexões (frontend)" +``` + +--- + +## Task 7: Frontend — `ConnectionsPage` + rota `/perfil/conexoes` + +**Files:** +- Create: `frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx` +- Modify: `frontend/src/app/AppRoutes.tsx` + +**Interfaces:** +- Consumes: `getConnections`, `disconnectProvider`, `connectProvider`, tipos de `connectionsApi`; `NewDashboardLayout`, `ProtectedRoute` (em `AppRoutes`). +- Produces: componente default `ConnectionsPage`; rota protegida `/perfil/conexoes`. + +- [ ] **Step 1: Write the page component** + +Regra do botão Desconectar: desabilitado quando `connected && totalMethods <= 1` (onde `totalMethods = providers conectados + (hasPassword ? 1 : 0)`). Lê `?linked=`/`?error=` para feedback. + +```tsx +// frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + connectProvider, + disconnectProvider, + getConnections, + type SupportedProvider, + type UserConnections, +} from "@/domains/auth/infrastructure/connectionsApi"; + +const PROVIDER_LABELS: Record = { + google: "Google", + linkedin: "LinkedIn", + github: "GitHub", +}; + +const ERROR_MESSAGES: Record = { + provider_already_linked: "Essa conta já está vinculada a outro usuário.", + link_failed: "Não foi possível conectar. Tente novamente.", +}; + +export default function ConnectionsPage() { + const [data, setData] = useState(null); + const [feedback, setFeedback] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + + async function refresh() { + try { + setData(await getConnections()); + } catch { + setError("Falha ao carregar conexões."); + } + } + + useEffect(() => { + refresh(); + }, []); + + useEffect(() => { + const linked = searchParams.get("linked"); + const errCode = searchParams.get("error"); + if (linked) setFeedback(`Conta ${linked} conectada com sucesso.`); + if (errCode) setError(ERROR_MESSAGES[errCode] ?? "Erro ao conectar."); + if (linked || errCode) { + searchParams.delete("linked"); + searchParams.delete("error"); + setSearchParams(searchParams, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const totalMethods = useMemo(() => { + if (!data) return 0; + const connected = data.connections.filter((c) => c.connected).length; + return connected + (data.hasPassword ? 1 : 0); + }, [data]); + + async function handleDisconnect(provider: SupportedProvider) { + setBusy(provider); + setError(""); + try { + await disconnectProvider(provider); + setFeedback(`Conta ${PROVIDER_LABELS[provider]} desconectada.`); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Falha ao desconectar."); + } finally { + setBusy(null); + } + } + + async function handleConnect(provider: SupportedProvider) { + setBusy(provider); + setError(""); + try { + await connectProvider(provider); + } catch (e) { + setError(e instanceof Error ? e.message : "Falha ao conectar."); + setBusy(null); + } + } + + return ( +
+

Gerenciar conexões

+

Conecte ou desconecte suas contas de login social.

+ + {feedback &&

{feedback}

} + {error &&

{error}

} + + {!data &&

Carregando…

} + + {data && ( +
    + {data.connections.map((c) => { + const isLastMethod = c.connected && totalMethods <= 1; + return ( +
  • + + {PROVIDER_LABELS[c.provider]}{" "} + {c.connected ? "· Conectado" : "· Não conectado"} + + {c.connected ? ( + + ) : ( + + )} +
  • + ); + })} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Add the route** + +Em `frontend/src/app/AppRoutes.tsx`, importe o componente e adicione a rota junto às demais (ex.: depois de `/auth/callback`). Use o mesmo layout do dashboard: + +```tsx +import ConnectionsPage from "@/domains/auth/presentation/pages/ConnectionsPage"; +``` + +```tsx + + + + + + } + /> +``` + +> Nota: `NewDashboardLayout` já é importado em `AppRoutes.tsx` (usado no `dashboardElement`). React Router v6 rankeia por especificidade, então `/perfil/conexoes` tem precedência sobre `/perfil` independentemente da ordem. + +- [ ] **Step 3: Typecheck + build** + +Run: `cd frontend && npx tsc --noEmit` +Expected: sem erros. + +- [ ] **Step 4: Manual smoke (com backend + infra rodando)** + +1. Logado, acesse `http://localhost:5173/perfil/conexoes`. +2. Clique **Conectar** num provider não conectado → completa OAuth → volta com `?linked=` e o provider aparece **Conectado**. +3. Com 2+ métodos, **Desconectar** um → some da lista de conectados. +4. Com só 1 método, o botão **Desconectar** fica desabilitado. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx frontend/src/app/AppRoutes.tsx +git commit -m "feat(PAV-7): tela Gerenciar conexões e rota /perfil/conexoes" +``` + +--- + +## Self-Review (feito pelo autor do plano) + +**Spec coverage:** +- Modelo `authProviders` → já existe (`accounts`); sem task, documentado no spec. ✔ +- Vincular quando email existe / não duplicar → já existe (`findOrCreateUser`) + reforço via Task 1 (conflito). ✔ +- Conectar logado → Task 5 (modo vínculo) + Task 6/7 (frontend). ✔ +- Desconectar com validação → Task 2 + Task 4 (rota) + Task 7 (UI). ✔ +- Listar conexões → Task 3 + Task 4 + Task 7. ✔ +- Tela "Gerenciar conexões" → Task 7. ✔ +- AC (Google+LinkedIn juntos; sem duplicidade) → coberto por Tasks 1, 5, 7. ✔ + +**Type consistency:** `SUPPORTED_PROVIDERS`/`ConnectionStatus`/`UserConnections` definidos na Task 3 e reusados nas Tasks 4/6/7 com os mesmos campos (`provider`, `connected`, `connectedAt`, `hasPassword`). Backend usa `Date`, frontend usa `string` (serialização JSON) — intencional e documentado. + +**Placeholder scan:** nenhum TODO/"handle errors" genérico; todo passo tem código real. From 36b03550a477a10ad679aa871c7c5a70becac2b2 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 12:59:22 -0300 Subject: [PATCH 04/14] =?UTF-8?q?feat(PAV-7):=20vincular=20provider=20a=20?= =?UTF-8?q?usu=C3=A1rio=20com=20checagem=20de=20conflito?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/functions/linkProviderToUser.ts | 28 +++++++++++ .../modules/users/linkProviderToUser.test.ts | 47 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 backend/src/modules/users/functions/linkProviderToUser.ts create mode 100644 backend/tests/unit/modules/users/linkProviderToUser.test.ts diff --git a/backend/src/modules/users/functions/linkProviderToUser.ts b/backend/src/modules/users/functions/linkProviderToUser.ts new file mode 100644 index 0000000..6524faf --- /dev/null +++ b/backend/src/modules/users/functions/linkProviderToUser.ts @@ -0,0 +1,28 @@ +import { db } from "../../../db/client"; +import { DB } from "../../../db/types/types"; +import { AppError } from "../../../lib/errors"; +import { OAuthProfile } from "../../types/auth.types"; +import { createAccount } from "./createAccount"; + +type LinkProviderParams = { + userId: string; + provider: string; + profile: OAuthProfile; +}; + +export async function linkProviderToUser( + { userId, provider, profile }: LinkProviderParams, + tx: DB = db, +): Promise { + const existingAccount = await tx.query.accounts.findFirst({ + where: (acc, { eq, and }) => + and(eq(acc.provider, provider), eq(acc.providerAccountId, profile.id)), + }); + + if (existingAccount) { + if (existingAccount.userId === userId) return; + throw AppError.conflict("Essa conta já está vinculada a outro usuário."); + } + + await createAccount({ userId, provider, profile }, tx); +} diff --git a/backend/tests/unit/modules/users/linkProviderToUser.test.ts b/backend/tests/unit/modules/users/linkProviderToUser.test.ts new file mode 100644 index 0000000..069cab6 --- /dev/null +++ b/backend/tests/unit/modules/users/linkProviderToUser.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindFirst: vi.fn(), + createAccount: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { query: { accounts: { findFirst: mocks.accountsFindFirst } } }, +})); + +vi.mock("../../../../src/modules/users/functions/createAccount.js", () => ({ + createAccount: mocks.createAccount, +})); + +import { linkProviderToUser } from "../../../../src/modules/users/functions/linkProviderToUser"; + +const profile = { id: "prov-123", email: "a@a.com" } as any; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("linkProviderToUser", () => { + it("cria account quando provider não está vinculado", async () => { + mocks.accountsFindFirst.mockResolvedValue(undefined); + await linkProviderToUser({ userId: "user-A", provider: "google", profile }); + expect(mocks.createAccount).toHaveBeenCalledWith( + { userId: "user-A", provider: "google", profile }, + expect.anything(), + ); + }); + + it("é idempotente quando já vinculado ao mesmo usuário", async () => { + mocks.accountsFindFirst.mockResolvedValue({ userId: "user-A" }); + await linkProviderToUser({ userId: "user-A", provider: "google", profile }); + expect(mocks.createAccount).not.toHaveBeenCalled(); + }); + + it("lança conflito quando vinculado a outro usuário", async () => { + mocks.accountsFindFirst.mockResolvedValue({ userId: "user-B" }); + await expect( + linkProviderToUser({ userId: "user-A", provider: "google", profile }), + ).rejects.toMatchObject({ code: "CONFLICT", statusCode: 409 }); + expect(mocks.createAccount).not.toHaveBeenCalled(); + }); +}); From d84baf6a004e7ea03a76b38698f8ce8fd9346ba2 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 13:01:02 -0300 Subject: [PATCH 05/14] =?UTF-8?q?feat(PAV-7):=20desconectar=20provider=20v?= =?UTF-8?q?alidando=20=C3=BAltimo=20m=C3=A9todo=20de=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/functions/disconnectProvider.ts | 38 +++++++++++++ .../modules/users/disconnectProvider.test.ts | 53 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 backend/src/modules/users/functions/disconnectProvider.ts create mode 100644 backend/tests/unit/modules/users/disconnectProvider.test.ts diff --git a/backend/src/modules/users/functions/disconnectProvider.ts b/backend/src/modules/users/functions/disconnectProvider.ts new file mode 100644 index 0000000..a0e1c41 --- /dev/null +++ b/backend/src/modules/users/functions/disconnectProvider.ts @@ -0,0 +1,38 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../../../db/client"; +import { accounts } from "../../../db/schema"; +import { DB } from "../../../db/types/types"; +import { AppError } from "../../../lib/errors"; + +type DisconnectParams = { userId: string; provider: string }; + +export async function disconnectProvider( + { userId, provider }: DisconnectParams, + tx: DB = db, +): Promise { + const userAccounts = await tx.query.accounts.findMany({ + where: (acc, { eq }) => eq(acc.userId, userId), + }); + + const hasPassword = Boolean( + await tx.query.credentials.findFirst({ + where: (c, { eq }) => eq(c.userId, userId), + }), + ); + + const remainingProviders = new Set( + userAccounts.map((a) => a.provider).filter((p) => p !== provider), + ); + + const methodsAfter = remainingProviders.size + (hasPassword ? 1 : 0); + + if (methodsAfter === 0) { + throw AppError.conflict( + "Não é possível desconectar seu último método de login.", + ); + } + + await tx + .delete(accounts) + .where(and(eq(accounts.userId, userId), eq(accounts.provider, provider))); +} diff --git a/backend/tests/unit/modules/users/disconnectProvider.test.ts b/backend/tests/unit/modules/users/disconnectProvider.test.ts new file mode 100644 index 0000000..d9f5746 --- /dev/null +++ b/backend/tests/unit/modules/users/disconnectProvider.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindMany: vi.fn(), + credentialsFindFirst: vi.fn(), + deleteWhere: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { + query: { + accounts: { findMany: mocks.accountsFindMany }, + credentials: { findFirst: mocks.credentialsFindFirst }, + }, + delete: vi.fn(() => ({ where: mocks.deleteWhere })), + }, +})); + +vi.mock("../../../../src/db/schema/index.js", () => ({ accounts: {} })); + +import { disconnectProvider } from "../../../../src/modules/users/functions/disconnectProvider"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("disconnectProvider", () => { + it("desconecta quando há outro método (outro provider)", async () => { + mocks.accountsFindMany.mockResolvedValue([ + { provider: "google" }, + { provider: "github" }, + ]); + mocks.credentialsFindFirst.mockResolvedValue(undefined); + await disconnectProvider({ userId: "user-A", provider: "google" }); + expect(mocks.deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("desconecta quando há senha como fallback", async () => { + mocks.accountsFindMany.mockResolvedValue([{ provider: "google" }]); + mocks.credentialsFindFirst.mockResolvedValue({ userId: "user-A" }); + await disconnectProvider({ userId: "user-A", provider: "google" }); + expect(mocks.deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("bloqueia quando é o último método", async () => { + mocks.accountsFindMany.mockResolvedValue([{ provider: "google" }]); + mocks.credentialsFindFirst.mockResolvedValue(undefined); + await expect( + disconnectProvider({ userId: "user-A", provider: "google" }), + ).rejects.toMatchObject({ code: "CONFLICT", statusCode: 409 }); + expect(mocks.deleteWhere).not.toHaveBeenCalled(); + }); +}); From 02f7e4593258b26db5e9139c081d0593e24aef29 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 13:03:04 -0300 Subject: [PATCH 06/14] =?UTF-8?q?feat(PAV-7):=20listar=20conex=C3=B5es=20d?= =?UTF-8?q?o=20usu=C3=A1rio=20com=20status=20por=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/functions/listUserConnections.ts | 42 +++++++++++++++++++ .../modules/users/listUserConnections.test.ts | 40 ++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 backend/src/modules/users/functions/listUserConnections.ts create mode 100644 backend/tests/unit/modules/users/listUserConnections.test.ts diff --git a/backend/src/modules/users/functions/listUserConnections.ts b/backend/src/modules/users/functions/listUserConnections.ts new file mode 100644 index 0000000..573bda5 --- /dev/null +++ b/backend/src/modules/users/functions/listUserConnections.ts @@ -0,0 +1,42 @@ +import { db } from "../../../db/client"; +import { DB } from "../../../db/types/types"; + +export const SUPPORTED_PROVIDERS = ["google", "linkedin", "github"] as const; +export type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]; + +export type ConnectionStatus = { + provider: SupportedProvider; + connected: boolean; + connectedAt: Date | null; +}; + +export type UserConnections = { + hasPassword: boolean; + connections: ConnectionStatus[]; +}; + +export async function listUserConnections( + userId: string, + tx: DB = db, +): Promise { + const userAccounts = await tx.query.accounts.findMany({ + where: (acc, { eq }) => eq(acc.userId, userId), + }); + + const hasPassword = Boolean( + await tx.query.credentials.findFirst({ + where: (c, { eq }) => eq(c.userId, userId), + }), + ); + + const connections: ConnectionStatus[] = SUPPORTED_PROVIDERS.map((provider) => { + const account = userAccounts.find((a) => a.provider === provider); + return { + provider, + connected: Boolean(account), + connectedAt: account?.createdAt ?? null, + }; + }); + + return { hasPassword, connections }; +} diff --git a/backend/tests/unit/modules/users/listUserConnections.test.ts b/backend/tests/unit/modules/users/listUserConnections.test.ts new file mode 100644 index 0000000..685e460 --- /dev/null +++ b/backend/tests/unit/modules/users/listUserConnections.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + accountsFindMany: vi.fn(), + credentialsFindFirst: vi.fn(), +})); + +vi.mock("../../../../src/db/client.js", () => ({ + db: { + query: { + accounts: { findMany: mocks.accountsFindMany }, + credentials: { findFirst: mocks.credentialsFindFirst }, + }, + }, +})); + +import { listUserConnections } from "../../../../src/modules/users/functions/listUserConnections"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("listUserConnections", () => { + it("marca conectados e retorna hasPassword", async () => { + const createdAt = new Date("2026-01-01T00:00:00Z"); + mocks.accountsFindMany.mockResolvedValue([ + { provider: "google", createdAt }, + ]); + mocks.credentialsFindFirst.mockResolvedValue({ userId: "user-A" }); + + const result = await listUserConnections("user-A"); + + expect(result.hasPassword).toBe(true); + expect(result.connections).toEqual([ + { provider: "google", connected: true, connectedAt: createdAt }, + { provider: "linkedin", connected: false, connectedAt: null }, + { provider: "github", connected: false, connectedAt: null }, + ]); + }); +}); From ed52b236e28cfd045f356897d966db5b46b10666 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 13:05:21 -0300 Subject: [PATCH 07/14] =?UTF-8?q?feat(PAV-7):=20endpoints=20GET/DELETE=20d?= =?UTF-8?q?e=20conex=C3=B5es=20do=20usu=C3=A1rio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/auth/connections.controller.ts | 28 +++++++ backend/src/routes/auth.routes.ts | 11 +++ .../routes/connections.routes.test.ts | 80 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 backend/src/modules/auth/connections.controller.ts create mode 100644 backend/tests/integration/routes/connections.routes.test.ts diff --git a/backend/src/modules/auth/connections.controller.ts b/backend/src/modules/auth/connections.controller.ts new file mode 100644 index 0000000..df3cd74 --- /dev/null +++ b/backend/src/modules/auth/connections.controller.ts @@ -0,0 +1,28 @@ +// backend/src/modules/auth/connections.controller.ts +import { Request, Response } from "express"; +import { AppError } from "../../lib/errors"; +import { disconnectProvider } from "../users/functions/disconnectProvider"; +import { + listUserConnections, + SUPPORTED_PROVIDERS, +} from "../users/functions/listUserConnections"; + +export class ConnectionsController { + async list(req: Request, res: Response) { + const userId = req.session.userId as string; + const result = await listUserConnections(userId); + return res.json(result); + } + + async disconnect(req: Request, res: Response) { + const userId = req.session.userId as string; + const provider = req.params.provider; + + if (!(SUPPORTED_PROVIDERS as readonly string[]).includes(provider)) { + throw AppError.validation("Provider inválido."); + } + + await disconnectProvider({ userId, provider }); + return res.json({ ok: true }); + } +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 10cf4c6..5fabeec 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -4,9 +4,11 @@ import { authAccountRateLimiter, authIpRateLimiter, } from "../middleware/rateLimit"; +import { requireAuth } from "../middleware/requireAuth"; import { validate } from "../middleware/validate"; import { AuthController } from "../modules/auth/auth.controller"; import { AuthService } from "../modules/auth/auth.service"; +import { ConnectionsController } from "../modules/auth/connections.controller"; import { CredentialsController } from "../modules/auth/credentials.controller"; import { CredentialsService } from "../modules/auth/credentials.service"; import { OAuthProviderSchema } from "../modules/types/auth.types"; @@ -21,6 +23,7 @@ const authService = new AuthService(); const authController = new AuthController(authService); const credentialsService = new CredentialsService(); const credentialsController = new CredentialsController(credentialsService); +const connectionsController = new ConnectionsController(); const providerParamsSchema = z.object({ provider: OAuthProviderSchema, @@ -38,6 +41,14 @@ router.get("/:provider/callback", (req, res, next) => { authController.callback(req, res).catch(next); }); +// Connections (usuário logado) +router.get("/connections", requireAuth, (req, res, next) => { + connectionsController.list(req, res).catch(next); +}); +router.delete("/connections/:provider", requireAuth, (req, res, next) => { + connectionsController.disconnect(req, res).catch(next); +}); + // Credentials router.post( "/register", diff --git a/backend/tests/integration/routes/connections.routes.test.ts b/backend/tests/integration/routes/connections.routes.test.ts new file mode 100644 index 0000000..0b84101 --- /dev/null +++ b/backend/tests/integration/routes/connections.routes.test.ts @@ -0,0 +1,80 @@ +// backend/tests/integration/routes/connections.routes.test.ts +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppError } from "../../../src/lib/errors"; + +const mocks = vi.hoisted(() => ({ + listUserConnections: vi.fn(), + disconnectProvider: vi.fn(), +})); + +vi.mock("../../../src/modules/users/functions/listUserConnections", () => ({ + listUserConnections: mocks.listUserConnections, + SUPPORTED_PROVIDERS: ["google", "linkedin", "github"], +})); + +vi.mock("../../../src/modules/users/functions/disconnectProvider", () => ({ + disconnectProvider: mocks.disconnectProvider, +})); + +// Sessão fake: injeta userId +vi.mock("../../../src/middleware/requireAuth", () => ({ + requireAuth: (req: any, _res: any, next: any) => { + req.session = { userId: "user-A" }; + next(); + }, +})); + +import { errorHandler } from "../../../src/middleware/errorHandler"; +import { ConnectionsController } from "../../../src/modules/auth/connections.controller"; +import { requireAuth } from "../../../src/middleware/requireAuth"; + +function buildApp() { + const app = express(); + app.use(express.json()); + const controller = new ConnectionsController(); + app.get("/auth/connections", requireAuth, (req, res, next) => + controller.list(req, res).catch(next), + ); + app.delete("/auth/connections/:provider", requireAuth, (req, res, next) => + controller.disconnect(req, res).catch(next), + ); + app.use(errorHandler); + return app; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("connections routes", () => { + it("GET /auth/connections retorna status", async () => { + mocks.listUserConnections.mockResolvedValue({ + hasPassword: false, + connections: [{ provider: "google", connected: true, connectedAt: null }], + }); + const res = await request(buildApp()).get("/auth/connections"); + expect(res.status).toBe(200); + expect(res.body.connections[0].provider).toBe("google"); + }); + + it("DELETE /auth/connections/:provider retorna 200", async () => { + mocks.disconnectProvider.mockResolvedValue(undefined); + const res = await request(buildApp()).delete("/auth/connections/google"); + expect(res.status).toBe(200); + expect(mocks.disconnectProvider).toHaveBeenCalledWith({ + userId: "user-A", + provider: "google", + }); + }); + + it("DELETE responde 409 no último método", async () => { + mocks.disconnectProvider.mockRejectedValue( + AppError.conflict("Não é possível desconectar seu último método de login."), + ); + const res = await request(buildApp()).delete("/auth/connections/google"); + expect(res.status).toBe(409); + expect(res.body.code).toBe("CONFLICT"); + }); +}); From 7a5071a2e194e6bf9ce351e29511f4903827dda8 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 13:08:24 -0300 Subject: [PATCH 08/14] =?UTF-8?q?feat(PAV-7):=20modo=20v=C3=ADnculo=20no?= =?UTF-8?q?=20callback=20OAuth=20(conectar=20provider=20logado)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/src/modules/auth/auth.controller.ts | 36 +++++++ .../modules/auth/auth.controller.link.test.ts | 98 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 backend/tests/unit/modules/auth/auth.controller.link.test.ts diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 5f35d7b..757a7eb 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -1,7 +1,9 @@ import { randomBytes } from "crypto"; import { Request, Response } from "express"; +import { AppError } from "../../lib/errors.js"; import type { OAuthProvider } from "../types/auth.types.js"; import { AuthCallbackParamsSchema } from "../types/auth.types.js"; +import { linkProviderToUser } from "../users/functions/linkProviderToUser.js"; import { AuthService } from "./auth.service.js"; export class AuthController { @@ -12,6 +14,13 @@ export class AuthController { const state = randomBytes(16).toString("hex"); (req.session as { oauth_state?: string }).oauth_state = state; + + if (req.query.intent === "link" && req.session.userId) { + (req.session as { oauth_intent?: string }).oauth_intent = "link"; + } else { + delete (req.session as { oauth_intent?: string }).oauth_intent; + } + await req.session.save(); const url = await this.authService.getAuthUrl(provider, state); @@ -43,6 +52,33 @@ export class AuthController { delete (req.session as { oauth_state?: string }).oauth_state; + const intent = (req.session as { oauth_intent?: string }).oauth_intent; + delete (req.session as { oauth_intent?: string }).oauth_intent; + + if (intent === "link" && req.session.userId) { + try { + const profile = await this.authService.getProfileFromProvider({ + ...params, + callbackUrl, + }); + await linkProviderToUser({ + userId: req.session.userId, + provider: params.provider, + profile, + }); + await req.session.save(); + return res.redirect( + `${frontendUrl}/perfil/conexoes?linked=${params.provider}`, + ); + } catch (linkError) { + const code = + linkError instanceof AppError + ? "provider_already_linked" + : "link_failed"; + return res.redirect(`${frontendUrl}/perfil/conexoes?error=${code}`); + } + } + const result = await this.authService.handleCallback({ ...params, callbackUrl, diff --git a/backend/tests/unit/modules/auth/auth.controller.link.test.ts b/backend/tests/unit/modules/auth/auth.controller.link.test.ts new file mode 100644 index 0000000..9296d5d --- /dev/null +++ b/backend/tests/unit/modules/auth/auth.controller.link.test.ts @@ -0,0 +1,98 @@ +// backend/tests/unit/modules/auth/auth.controller.link.test.ts +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AppError } from "../../../../src/lib/errors"; + +const mocks = vi.hoisted(() => ({ + linkProviderToUser: vi.fn(), + getProfileFromProvider: vi.fn(), + handleCallback: vi.fn(), +})); + +vi.mock("../../../../src/modules/users/functions/linkProviderToUser", () => ({ + linkProviderToUser: mocks.linkProviderToUser, +})); + +import { AuthController } from "../../../../src/modules/auth/auth.controller"; + +function fakeRes() { + return { + redirect: vi.fn(), + json: vi.fn(), + status: vi.fn().mockReturnThis(), + } as any; +} + +function fakeService() { + return { + getProfileFromProvider: mocks.getProfileFromProvider, + handleCallback: mocks.handleCallback, + getAuthUrl: vi.fn(), + } as any; +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.FRONTEND_URL = "http://localhost:5173"; + process.env.APP_URL = "http://localhost:3001"; +}); + +describe("AuthController callback — modo vínculo", () => { + it("vincula e redireciona para /perfil/conexoes?linked= sem trocar sessão", async () => { + mocks.getProfileFromProvider.mockResolvedValue({ id: "prov-1" }); + mocks.linkProviderToUser.mockResolvedValue(undefined); + + const controller = new AuthController(fakeService()); + const save = vi.fn(); + const req = { + params: { provider: "google" }, + query: { code: "c", state: "s" }, + session: { + userId: "user-A", + role: "user", + oauth_state: "s", + oauth_intent: "link", + save, + }, + } as any; + const res = fakeRes(); + + await controller.callback(req, res); + + expect(mocks.linkProviderToUser).toHaveBeenCalledWith({ + userId: "user-A", + provider: "google", + profile: { id: "prov-1" }, + }); + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/perfil/conexoes?linked=google", + ); + expect(mocks.handleCallback).not.toHaveBeenCalled(); + }); + + it("redireciona com erro quando o provider já está vinculado", async () => { + mocks.getProfileFromProvider.mockResolvedValue({ id: "prov-1" }); + mocks.linkProviderToUser.mockRejectedValue( + AppError.conflict("já vinculado"), + ); + + const controller = new AuthController(fakeService()); + const req = { + params: { provider: "google" }, + query: { code: "c", state: "s" }, + session: { + userId: "user-A", + role: "user", + oauth_state: "s", + oauth_intent: "link", + save: vi.fn(), + }, + } as any; + const res = fakeRes(); + + await controller.callback(req, res); + + expect(res.redirect).toHaveBeenCalledWith( + "http://localhost:5173/perfil/conexoes?error=provider_already_linked", + ); + }); +}); From 37045f1b5640427ac0e5f535baeb6a2b6768cb4f Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 13:10:12 -0300 Subject: [PATCH 09/14] =?UTF-8?q?feat(PAV-7):=20client=20de=20API=20de=20c?= =?UTF-8?q?onex=C3=B5es=20(frontend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/infrastructure/connectionsApi.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 frontend/src/domains/auth/infrastructure/connectionsApi.ts diff --git a/frontend/src/domains/auth/infrastructure/connectionsApi.ts b/frontend/src/domains/auth/infrastructure/connectionsApi.ts new file mode 100644 index 0000000..7dce887 --- /dev/null +++ b/frontend/src/domains/auth/infrastructure/connectionsApi.ts @@ -0,0 +1,59 @@ +export type SupportedProvider = "google" | "linkedin" | "github"; + +export type ConnectionStatus = { + provider: SupportedProvider; + connected: boolean; + connectedAt: string | null; +}; + +export type UserConnections = { + hasPassword: boolean; + connections: ConnectionStatus[]; +}; + +function getBaseUrl(): string { + const base = import.meta.env.VITE_API_BASE_URL; + if (base && base.trim().length > 0) return base.replace(/\/+$/, ""); + return ""; +} + +function buildUrl(path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + const base = getBaseUrl(); + return base ? `${base}${normalized}` : normalized; +} + +export async function getConnections(): Promise { + const response = await fetch(buildUrl("/auth/connections"), { + credentials: "include", + }); + if (!response.ok) throw new Error("Falha ao carregar conexões."); + return (await response.json()) as UserConnections; +} + +export async function disconnectProvider( + provider: SupportedProvider, +): Promise { + const response = await fetch(buildUrl(`/auth/connections/${provider}`), { + method: "DELETE", + credentials: "include", + }); + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { + message?: string; + }; + throw new Error(payload.message ?? "Falha ao desconectar."); + } +} + +export async function connectProvider( + provider: SupportedProvider, +): Promise { + const response = await fetch( + buildUrl(`/auth/${provider}/url?intent=link`), + { credentials: "include" }, + ); + if (!response.ok) throw new Error("Falha ao iniciar conexão."); + const { url } = (await response.json()) as { url: string }; + window.location.href = url; +} From 4555c9df349e93b3da6113df13a540007d99c2af Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 13:14:37 -0300 Subject: [PATCH 10/14] =?UTF-8?q?feat(PAV-7):=20tela=20Gerenciar=20conex?= =?UTF-8?q?=C3=B5es=20e=20rota=20/perfil/conexoes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/AppRoutes.tsx | 11 ++ .../presentation/pages/ConnectionsPage.tsx | 152 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index f165331..a4b7ff9 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -1,6 +1,7 @@ import NotFound from "@/app/NotFound"; import { useAuth } from "@/domains/auth/application/AuthContext"; import AuthCallbackPage from "@/domains/auth/presentation/pages/AuthCallbackPage"; +import ConnectionsPage from "@/domains/auth/presentation/pages/ConnectionsPage"; import LoginPage from "@/domains/auth/presentation/pages/LoginPage"; import RegisterPage from "@/domains/auth/presentation/pages/RegisterPage"; import LandingPage from "@/domains/marketing/presentation/pages/LandingPage"; @@ -58,6 +59,16 @@ export function AppRoutes() { } /> } /> + + + + + + } + /> } /> ); diff --git a/frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx b/frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx new file mode 100644 index 0000000..b55b169 --- /dev/null +++ b/frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx @@ -0,0 +1,152 @@ +// frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + connectProvider, + disconnectProvider, + getConnections, + type SupportedProvider, + type UserConnections, +} from "@/domains/auth/infrastructure/connectionsApi"; + +const PROVIDER_LABELS: Record = { + google: "Google", + linkedin: "LinkedIn", + github: "GitHub", +}; + +const ERROR_MESSAGES: Record = { + provider_already_linked: "Essa conta já está vinculada a outro usuário.", + link_failed: "Não foi possível conectar. Tente novamente.", +}; + +export default function ConnectionsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + + // Read ?linked= / ?error= once at mount; derive initial state synchronously. + const initialLinked = searchParams.get("linked"); + const initialErrCode = searchParams.get("error"); + + const [data, setData] = useState(null); + const [feedback, setFeedback] = useState( + initialLinked ? `Conta ${initialLinked} conectada com sucesso.` : "", + ); + const [error, setError] = useState( + initialErrCode + ? (ERROR_MESSAGES[initialErrCode] ?? "Erro ao conectar.") + : "", + ); + const [busy, setBusy] = useState(null); + + // Clean up URL params after reading them (no setState in effect body). + useEffect(() => { + if (initialLinked || initialErrCode) { + const next = new URLSearchParams(searchParams); + next.delete("linked"); + next.delete("error"); + setSearchParams(next, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Load connections list on mount. + useEffect(() => { + getConnections() + .then((result) => setData(result)) + .catch(() => setError("Falha ao carregar conexões.")); + }, []); + + const totalMethods = useMemo(() => { + if (!data) return 0; + const connected = data.connections.filter((c) => c.connected).length; + return connected + (data.hasPassword ? 1 : 0); + }, [data]); + + function refresh() { + getConnections() + .then((result) => setData(result)) + .catch(() => setError("Falha ao carregar conexões.")); + } + + async function handleDisconnect(provider: SupportedProvider) { + setBusy(provider); + setError(""); + try { + await disconnectProvider(provider); + setFeedback(`Conta ${PROVIDER_LABELS[provider]} desconectada.`); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Falha ao desconectar."); + } finally { + setBusy(null); + } + } + + async function handleConnect(provider: SupportedProvider) { + setBusy(provider); + setError(""); + try { + await connectProvider(provider); + } catch (e) { + setError(e instanceof Error ? e.message : "Falha ao conectar."); + setBusy(null); + } + } + + return ( +
+

Gerenciar conexões

+

Conecte ou desconecte suas contas de login social.

+ + {feedback &&

{feedback}

} + {error &&

{error}

} + + {!data &&

Carregando…

} + + {data && ( +
    + {data.connections.map((c) => { + const isLastMethod = c.connected && totalMethods <= 1; + return ( +
  • + + {PROVIDER_LABELS[c.provider]}{" "} + {c.connected ? "· Conectado" : "· Não conectado"} + + {c.connected ? ( + + ) : ( + + )} +
  • + ); + })} +
+ )} +
+ ); +} From 34d9294222b5da19940f844c76187c8da2456a4d Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 13:21:25 -0300 Subject: [PATCH 11/14] =?UTF-8?q?fix(PAV-7):=20disconnect=20at=C3=B4mico?= =?UTF-8?q?=20com=20lock=20+=20hardening=20de=20erros=20e=20testes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - disconnectProvider: wraps read+guard+delete em db.transaction com SELECT FOR UPDATE - auth.controller: discrimina erro de link por code === "CONFLICT" - connections.routes.test: adiciona teste 400 para provider nao suportado - disconnectProvider.test: atualiza mock para API de transacao/locking Co-Authored-By: Claude Sonnet 4.6 --- backend/src/modules/auth/auth.controller.ts | 2 +- .../users/functions/disconnectProvider.ts | 20 ++++++--- .../routes/connections.routes.test.ts | 7 ++++ .../modules/users/disconnectProvider.test.ts | 42 +++++++++++++++---- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 757a7eb..d1291b9 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -72,7 +72,7 @@ export class AuthController { ); } catch (linkError) { const code = - linkError instanceof AppError + linkError instanceof AppError && linkError.code === "CONFLICT" ? "provider_already_linked" : "link_failed"; return res.redirect(`${frontendUrl}/perfil/conexoes?error=${code}`); diff --git a/backend/src/modules/users/functions/disconnectProvider.ts b/backend/src/modules/users/functions/disconnectProvider.ts index a0e1c41..24edf53 100644 --- a/backend/src/modules/users/functions/disconnectProvider.ts +++ b/backend/src/modules/users/functions/disconnectProvider.ts @@ -6,13 +6,15 @@ import { AppError } from "../../../lib/errors"; type DisconnectParams = { userId: string; provider: string }; -export async function disconnectProvider( +async function runDisconnect( { userId, provider }: DisconnectParams, - tx: DB = db, + tx: DB, ): Promise { - const userAccounts = await tx.query.accounts.findMany({ - where: (acc, { eq }) => eq(acc.userId, userId), - }); + const userAccounts = await tx + .select() + .from(accounts) + .where(eq(accounts.userId, userId)) + .for("update"); const hasPassword = Boolean( await tx.query.credentials.findFirst({ @@ -36,3 +38,11 @@ export async function disconnectProvider( .delete(accounts) .where(and(eq(accounts.userId, userId), eq(accounts.provider, provider))); } + +export async function disconnectProvider( + params: DisconnectParams, + tx?: DB, +): Promise { + if (tx) return runDisconnect(params, tx); + return db.transaction((t) => runDisconnect(params, t as unknown as DB)); +} diff --git a/backend/tests/integration/routes/connections.routes.test.ts b/backend/tests/integration/routes/connections.routes.test.ts index 0b84101..6347ae4 100644 --- a/backend/tests/integration/routes/connections.routes.test.ts +++ b/backend/tests/integration/routes/connections.routes.test.ts @@ -77,4 +77,11 @@ describe("connections routes", () => { expect(res.status).toBe(409); expect(res.body.code).toBe("CONFLICT"); }); + + it("DELETE responde 400 para provider não suportado", async () => { + const res = await request(buildApp()).delete("/auth/connections/facebook"); + expect(res.status).toBe(400); + expect(res.body.code).toBe("VALIDATION_ERROR"); + expect(mocks.disconnectProvider).not.toHaveBeenCalled(); + }); }); diff --git a/backend/tests/unit/modules/users/disconnectProvider.test.ts b/backend/tests/unit/modules/users/disconnectProvider.test.ts index d9f5746..9bb10d1 100644 --- a/backend/tests/unit/modules/users/disconnectProvider.test.ts +++ b/backend/tests/unit/modules/users/disconnectProvider.test.ts @@ -1,18 +1,32 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - accountsFindMany: vi.fn(), + selectForUpdate: vi.fn(), credentialsFindFirst: vi.fn(), deleteWhere: vi.fn(), })); +// mockTx mirrors what runDisconnect uses inside the transaction: +// tx.select().from().where().for("update") → resolves to accounts array +// tx.query.credentials.findFirst → resolves to credential or undefined +// tx.delete().where() → called to delete the account row +const mockTx = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + for: mocks.selectForUpdate, + })), + })), + })), + query: { + credentials: { findFirst: mocks.credentialsFindFirst }, + }, + delete: vi.fn(() => ({ where: mocks.deleteWhere })), +}; + vi.mock("../../../../src/db/client.js", () => ({ db: { - query: { - accounts: { findMany: mocks.accountsFindMany }, - credentials: { findFirst: mocks.credentialsFindFirst }, - }, - delete: vi.fn(() => ({ where: mocks.deleteWhere })), + transaction: (cb: (tx: typeof mockTx) => Promise) => cb(mockTx), }, })); @@ -22,11 +36,21 @@ import { disconnectProvider } from "../../../../src/modules/users/functions/disc beforeEach(() => { vi.clearAllMocks(); + // Re-wire the chainable select mock after clearAllMocks resets call counts + // (the implementations are already defined above — just restore them) + mockTx.select.mockReturnValue({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + for: mocks.selectForUpdate, + })), + })), + }); + mockTx.delete.mockReturnValue({ where: mocks.deleteWhere }); }); describe("disconnectProvider", () => { it("desconecta quando há outro método (outro provider)", async () => { - mocks.accountsFindMany.mockResolvedValue([ + mocks.selectForUpdate.mockResolvedValue([ { provider: "google" }, { provider: "github" }, ]); @@ -36,14 +60,14 @@ describe("disconnectProvider", () => { }); it("desconecta quando há senha como fallback", async () => { - mocks.accountsFindMany.mockResolvedValue([{ provider: "google" }]); + mocks.selectForUpdate.mockResolvedValue([{ provider: "google" }]); mocks.credentialsFindFirst.mockResolvedValue({ userId: "user-A" }); await disconnectProvider({ userId: "user-A", provider: "google" }); expect(mocks.deleteWhere).toHaveBeenCalledTimes(1); }); it("bloqueia quando é o último método", async () => { - mocks.accountsFindMany.mockResolvedValue([{ provider: "google" }]); + mocks.selectForUpdate.mockResolvedValue([{ provider: "google" }]); mocks.credentialsFindFirst.mockResolvedValue(undefined); await expect( disconnectProvider({ userId: "user-A", provider: "google" }), From 04015545b3cd00ee560f2a8fe00b01ff76bfd979 Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 14:03:06 -0300 Subject: [PATCH 12/14] feat(PAV-7): conexoes como secao dentro do perfil Move a gestao de conexoes sociais para uma secao no ProfileTab, seguindo o design system dos demais cards do perfil, e aposenta a pagina/rota standalone /perfil/conexoes. - ConnectionsForm: nova secao no perfil (getConnections/connect/ disconnect, guarda de ultimo metodo de login, feedback via ?linked=/?error=) - ProfileTab: renderiza ConnectionsForm como terceira secao - AppRoutes: /perfil/conexoes vira redirect para /perfil; ConnectionsPage removida - auth.controller: redirect do vinculo OAuth aponta para /perfil (+ teste atualizado) - vite.config: navegacoes de pagina do OAuth caem no SPA em vez do proxy de API Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/modules/auth/auth.controller.ts | 4 +- .../modules/auth/auth.controller.link.test.ts | 6 +- frontend/src/app/AppRoutes.tsx | 9 +- .../presentation/pages/ConnectionsPage.tsx | 152 --------------- .../components/profile/ConnectionsForm.tsx | 182 ++++++++++++++++++ .../components/profile/ProfileTab.tsx | 2 + frontend/vite.config.js | 7 + 7 files changed, 197 insertions(+), 165 deletions(-) delete mode 100644 frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx create mode 100644 frontend/src/domains/new_dashboard/components/profile/ConnectionsForm.tsx diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index d1291b9..45ea5ca 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -68,14 +68,14 @@ export class AuthController { }); await req.session.save(); return res.redirect( - `${frontendUrl}/perfil/conexoes?linked=${params.provider}`, + `${frontendUrl}/perfil?linked=${params.provider}`, ); } catch (linkError) { const code = linkError instanceof AppError && linkError.code === "CONFLICT" ? "provider_already_linked" : "link_failed"; - return res.redirect(`${frontendUrl}/perfil/conexoes?error=${code}`); + return res.redirect(`${frontendUrl}/perfil?error=${code}`); } } diff --git a/backend/tests/unit/modules/auth/auth.controller.link.test.ts b/backend/tests/unit/modules/auth/auth.controller.link.test.ts index 9296d5d..7d09a7a 100644 --- a/backend/tests/unit/modules/auth/auth.controller.link.test.ts +++ b/backend/tests/unit/modules/auth/auth.controller.link.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { }); describe("AuthController callback — modo vínculo", () => { - it("vincula e redireciona para /perfil/conexoes?linked= sem trocar sessão", async () => { + it("vincula e redireciona para /perfil?linked= sem trocar sessão", async () => { mocks.getProfileFromProvider.mockResolvedValue({ id: "prov-1" }); mocks.linkProviderToUser.mockResolvedValue(undefined); @@ -64,7 +64,7 @@ describe("AuthController callback — modo vínculo", () => { profile: { id: "prov-1" }, }); expect(res.redirect).toHaveBeenCalledWith( - "http://localhost:5173/perfil/conexoes?linked=google", + "http://localhost:5173/perfil?linked=google", ); expect(mocks.handleCallback).not.toHaveBeenCalled(); }); @@ -92,7 +92,7 @@ describe("AuthController callback — modo vínculo", () => { await controller.callback(req, res); expect(res.redirect).toHaveBeenCalledWith( - "http://localhost:5173/perfil/conexoes?error=provider_already_linked", + "http://localhost:5173/perfil?error=provider_already_linked", ); }); }); diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index a4b7ff9..f60d67b 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -1,7 +1,6 @@ import NotFound from "@/app/NotFound"; import { useAuth } from "@/domains/auth/application/AuthContext"; import AuthCallbackPage from "@/domains/auth/presentation/pages/AuthCallbackPage"; -import ConnectionsPage from "@/domains/auth/presentation/pages/ConnectionsPage"; import LoginPage from "@/domains/auth/presentation/pages/LoginPage"; import RegisterPage from "@/domains/auth/presentation/pages/RegisterPage"; import LandingPage from "@/domains/marketing/presentation/pages/LandingPage"; @@ -61,13 +60,7 @@ export function AppRoutes() { } /> - - - - - } + element={} /> } /> diff --git a/frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx b/frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx deleted file mode 100644 index b55b169..0000000 --- a/frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx +++ /dev/null @@ -1,152 +0,0 @@ -// frontend/src/domains/auth/presentation/pages/ConnectionsPage.tsx -import { useEffect, useMemo, useState } from "react"; -import { useSearchParams } from "react-router-dom"; -import { - connectProvider, - disconnectProvider, - getConnections, - type SupportedProvider, - type UserConnections, -} from "@/domains/auth/infrastructure/connectionsApi"; - -const PROVIDER_LABELS: Record = { - google: "Google", - linkedin: "LinkedIn", - github: "GitHub", -}; - -const ERROR_MESSAGES: Record = { - provider_already_linked: "Essa conta já está vinculada a outro usuário.", - link_failed: "Não foi possível conectar. Tente novamente.", -}; - -export default function ConnectionsPage() { - const [searchParams, setSearchParams] = useSearchParams(); - - // Read ?linked= / ?error= once at mount; derive initial state synchronously. - const initialLinked = searchParams.get("linked"); - const initialErrCode = searchParams.get("error"); - - const [data, setData] = useState(null); - const [feedback, setFeedback] = useState( - initialLinked ? `Conta ${initialLinked} conectada com sucesso.` : "", - ); - const [error, setError] = useState( - initialErrCode - ? (ERROR_MESSAGES[initialErrCode] ?? "Erro ao conectar.") - : "", - ); - const [busy, setBusy] = useState(null); - - // Clean up URL params after reading them (no setState in effect body). - useEffect(() => { - if (initialLinked || initialErrCode) { - const next = new URLSearchParams(searchParams); - next.delete("linked"); - next.delete("error"); - setSearchParams(next, { replace: true }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Load connections list on mount. - useEffect(() => { - getConnections() - .then((result) => setData(result)) - .catch(() => setError("Falha ao carregar conexões.")); - }, []); - - const totalMethods = useMemo(() => { - if (!data) return 0; - const connected = data.connections.filter((c) => c.connected).length; - return connected + (data.hasPassword ? 1 : 0); - }, [data]); - - function refresh() { - getConnections() - .then((result) => setData(result)) - .catch(() => setError("Falha ao carregar conexões.")); - } - - async function handleDisconnect(provider: SupportedProvider) { - setBusy(provider); - setError(""); - try { - await disconnectProvider(provider); - setFeedback(`Conta ${PROVIDER_LABELS[provider]} desconectada.`); - refresh(); - } catch (e) { - setError(e instanceof Error ? e.message : "Falha ao desconectar."); - } finally { - setBusy(null); - } - } - - async function handleConnect(provider: SupportedProvider) { - setBusy(provider); - setError(""); - try { - await connectProvider(provider); - } catch (e) { - setError(e instanceof Error ? e.message : "Falha ao conectar."); - setBusy(null); - } - } - - return ( -
-

Gerenciar conexões

-

Conecte ou desconecte suas contas de login social.

- - {feedback &&

{feedback}

} - {error &&

{error}

} - - {!data &&

Carregando…

} - - {data && ( -
    - {data.connections.map((c) => { - const isLastMethod = c.connected && totalMethods <= 1; - return ( -
  • - - {PROVIDER_LABELS[c.provider]}{" "} - {c.connected ? "· Conectado" : "· Não conectado"} - - {c.connected ? ( - - ) : ( - - )} -
  • - ); - })} -
- )} -
- ); -} diff --git a/frontend/src/domains/new_dashboard/components/profile/ConnectionsForm.tsx b/frontend/src/domains/new_dashboard/components/profile/ConnectionsForm.tsx new file mode 100644 index 0000000..3fad02b --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/profile/ConnectionsForm.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + connectProvider, + disconnectProvider, + getConnections, + type SupportedProvider, + type UserConnections, +} from "@/domains/auth/infrastructure/connectionsApi"; + +const PROVIDER_LABELS: Record = { + google: "Google", + linkedin: "LinkedIn", + github: "GitHub", +}; + +const ERROR_MESSAGES: Record = { + provider_already_linked: "Essa conta já está vinculada a outro usuário.", + link_failed: "Não foi possível conectar. Tente novamente.", +}; + +export function ConnectionsForm() { + const [searchParams, setSearchParams] = useSearchParams(); + + // Read ?linked= / ?error= once at mount; derive initial state synchronously. + const initialLinked = searchParams.get("linked"); + const initialErrCode = searchParams.get("error"); + + const [data, setData] = useState(null); + const [feedback, setFeedback] = useState( + initialLinked ? `Conta ${initialLinked} conectada com sucesso.` : "", + ); + const [error, setError] = useState( + initialErrCode + ? (ERROR_MESSAGES[initialErrCode] ?? "Erro ao conectar.") + : "", + ); + const [busy, setBusy] = useState(null); + + // Clean up URL params after reading them (no setState in effect body). + useEffect(() => { + if (initialLinked || initialErrCode) { + const next = new URLSearchParams(searchParams); + next.delete("linked"); + next.delete("error"); + setSearchParams(next, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Load connections list on mount. + useEffect(() => { + getConnections() + .then((result) => setData(result)) + .catch(() => setError("Falha ao carregar conexões.")); + }, []); + + const totalMethods = useMemo(() => { + if (!data) return 0; + const connected = data.connections.filter((c) => c.connected).length; + return connected + (data.hasPassword ? 1 : 0); + }, [data]); + + function refresh() { + getConnections() + .then((result) => setData(result)) + .catch(() => setError("Falha ao carregar conexões.")); + } + + async function handleDisconnect(provider: SupportedProvider) { + setBusy(provider); + setError(""); + setFeedback(""); + try { + await disconnectProvider(provider); + setFeedback(`Conta ${PROVIDER_LABELS[provider]} desconectada.`); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Falha ao desconectar."); + } finally { + setBusy(null); + } + } + + async function handleConnect(provider: SupportedProvider) { + setBusy(provider); + setError(""); + setFeedback(""); + try { + await connectProvider(provider); + } catch (e) { + setError(e instanceof Error ? e.message : "Falha ao conectar."); + setBusy(null); + } + } + + return ( +
+

Conexões

+

+ Conecte ou desconecte suas contas de login social. +

+ + {feedback ? ( +

+ {feedback} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + +
+ {!data ? ( +

Carregando…

+ ) : ( +
    + {data.connections.map((c) => { + const isLastMethod = c.connected && totalMethods <= 1; + const isBusy = busy === c.provider; + + return ( +
  • + + + {PROVIDER_LABELS[c.provider]} + + + {c.connected ? "Conectado" : "Não conectado"} + + + + {c.connected ? ( + + ) : ( + + )} +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx b/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx index 762c475..4b9fe5b 100644 --- a/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx +++ b/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx @@ -1,4 +1,5 @@ import type { SearchPreferences, UserProfile } from "../../types"; +import { ConnectionsForm } from "./ConnectionsForm"; import { PreferencesForm } from "./PreferencesForm"; import { ProfileForm } from "./ProfileForm"; @@ -37,6 +38,7 @@ export function ProfileTab({ isSaving={isSavingPreferences} onSave={onSavePreferences} /> + ); } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 3913373..bcd59f4 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -19,6 +19,13 @@ export default defineConfig({ "^/(auth|users|jobs|keywords|saved-jobs)": { target: apiTarget, changeOrigin: true, + // Deixa navegações de página (ex: redirect OAuth para /auth/callback) + // caírem no index.html do SPA; só proxia chamadas de API (fetch/XHR). + bypass(req) { + if (req.method === "GET" && req.headers.accept?.includes("text/html")) { + return req.url; + } + }, }, }, }, From eff7cfc84b4f46b5f4f44894b1bdfa3e2ea8415a Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 16:49:00 -0300 Subject: [PATCH 13/14] test(PAV-7): cobre ConnectionsForm e restaura threshold de branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O CI (vitest run --coverage) reprovava com branches globais em 78.26% (< 80%) por falta de testes do ConnectionsForm. Adiciona testes cobrindo feedback via query params, carga com sucesso/erro, conectar, desconectar (sucesso e erros) e guarda de último método de login. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../new_dashboard/connectionsForm.test.tsx | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 frontend/tests/unit/new_dashboard/connectionsForm.test.tsx diff --git a/frontend/tests/unit/new_dashboard/connectionsForm.test.tsx b/frontend/tests/unit/new_dashboard/connectionsForm.test.tsx new file mode 100644 index 0000000..ae6c791 --- /dev/null +++ b/frontend/tests/unit/new_dashboard/connectionsForm.test.tsx @@ -0,0 +1,180 @@ +import { ConnectionsForm } from "@/domains/new_dashboard/components/profile/ConnectionsForm"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getConnections: vi.fn(), + connectProvider: vi.fn(), + disconnectProvider: vi.fn(), +})); + +vi.mock("@/domains/auth/infrastructure/connectionsApi", () => ({ + getConnections: mocks.getConnections, + connectProvider: mocks.connectProvider, + disconnectProvider: mocks.disconnectProvider, +})); + +const dataWithPassword = { + hasPassword: true, + connections: [ + { provider: "google", connected: true, connectedAt: "2024-01-01" }, + { provider: "linkedin", connected: false, connectedAt: null }, + { provider: "github", connected: false, connectedAt: null }, + ], +}; + +const dataOnlyOneMethod = { + hasPassword: false, + connections: [ + { provider: "google", connected: true, connectedAt: "2024-01-01" }, + { provider: "linkedin", connected: false, connectedAt: null }, + { provider: "github", connected: false, connectedAt: null }, + ], +}; + +function renderForm(initialEntries: string[] = ["/perfil"]) { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getConnections.mockResolvedValue(dataWithPassword); + mocks.connectProvider.mockResolvedValue(undefined); + mocks.disconnectProvider.mockResolvedValue(undefined); +}); + +describe("ConnectionsForm", () => { + it("mostra carregando e depois lista as conexões", async () => { + renderForm(); + + expect(screen.getByText(/carregando/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("Google")).toBeInTheDocument(); + expect(screen.getByText("LinkedIn")).toBeInTheDocument(); + expect(screen.getByText("GitHub")).toBeInTheDocument(); + }); + expect(screen.getByText("Conectado")).toBeInTheDocument(); + expect(screen.getAllByText("Não conectado")).toHaveLength(2); + }); + + it("exibe feedback de sucesso a partir de ?linked=", async () => { + renderForm(["/perfil?linked=google"]); + + expect( + screen.getByText(/conta google conectada com sucesso/i), + ).toBeInTheDocument(); + await waitFor(() => + expect(screen.getByText("Google")).toBeInTheDocument(), + ); + }); + + it("exibe mensagem mapeada a partir de ?error= conhecido", async () => { + renderForm(["/perfil?error=provider_already_linked"]); + + expect( + screen.getByText(/já está vinculada a outro usuário/i), + ).toBeInTheDocument(); + }); + + it("exibe mensagem genérica a partir de ?error= desconhecido", async () => { + renderForm(["/perfil?error=algo_inesperado"]); + + expect(screen.getByText("Erro ao conectar.")).toBeInTheDocument(); + }); + + it("mostra erro quando o carregamento de conexões falha", async () => { + mocks.getConnections.mockRejectedValueOnce(new Error("network")); + renderForm(); + + await waitFor(() => + expect(screen.getByText("Falha ao carregar conexões.")).toBeInTheDocument(), + ); + }); + + it("desconecta um provider conectado e mostra feedback", async () => { + renderForm(); + await waitFor(() => expect(screen.getByText("Google")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Desconectar" })); + + await waitFor(() => + expect(mocks.disconnectProvider).toHaveBeenCalledWith("google"), + ); + await waitFor(() => + expect( + screen.getByText("Conta Google desconectada."), + ).toBeInTheDocument(), + ); + // refresh: carrega novamente após desconectar + expect(mocks.getConnections).toHaveBeenCalledTimes(2); + }); + + it("mostra a mensagem de erro ao falhar a desconexão (Error)", async () => { + mocks.disconnectProvider.mockRejectedValueOnce(new Error("boom")); + renderForm(); + await waitFor(() => expect(screen.getByText("Google")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Desconectar" })); + + await waitFor(() => expect(screen.getByText("boom")).toBeInTheDocument()); + }); + + it("usa fallback quando a desconexão falha sem Error", async () => { + mocks.disconnectProvider.mockRejectedValueOnce("x"); + renderForm(); + await waitFor(() => expect(screen.getByText("Google")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Desconectar" })); + + await waitFor(() => + expect(screen.getByText("Falha ao desconectar.")).toBeInTheDocument(), + ); + }); + + it("inicia a conexão de um provider não conectado", async () => { + renderForm(); + await waitFor(() => + expect(screen.getByText("LinkedIn")).toBeInTheDocument(), + ); + + // Ordem: google (Desconectar), linkedin, github (Conectar) + fireEvent.click(screen.getAllByRole("button", { name: "Conectar" })[0]); + + await waitFor(() => + expect(mocks.connectProvider).toHaveBeenCalledWith("linkedin"), + ); + }); + + it("mostra a mensagem de erro ao falhar o início da conexão", async () => { + mocks.connectProvider.mockRejectedValueOnce(new Error("fail-connect")); + renderForm(); + await waitFor(() => + expect(screen.getByText("LinkedIn")).toBeInTheDocument(), + ); + + fireEvent.click(screen.getAllByRole("button", { name: "Conectar" })[0]); + + await waitFor(() => + expect(screen.getByText("fail-connect")).toBeInTheDocument(), + ); + }); + + it("desabilita desconectar quando é o único método de login", async () => { + mocks.getConnections.mockResolvedValue(dataOnlyOneMethod); + renderForm(); + await waitFor(() => expect(screen.getByText("Google")).toBeInTheDocument()); + + const disconnect = screen.getByRole("button", { name: "Desconectar" }); + expect(disconnect).toBeDisabled(); + expect(disconnect).toHaveAttribute( + "title", + "Você precisa manter ao menos um método de login.", + ); + }); +}); From c72bb26a726d5cf1c64e4bdae3b3fd3f16be1a6b Mon Sep 17 00:00:00 2001 From: Jeremias Santos Date: Mon, 27 Jul 2026 16:57:08 -0300 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20substituir=20op=C3=A7=C3=A3o=20"Se?= =?UTF-8?q?guran=C3=A7a"=20por=20"Ajuda"=20no=20dropdown=20superior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove o botão morto "Segurança" do dropdown do menu superior. A opção "Ajuda" (navega para /ajuda, mesmo destino da Ajuda no canto inferior esquerdo) já existia logo abaixo e passa a ocupar o lugar, sem duplicar. Adiciona teste do dropdown cobrindo os critérios de aceite (PAV-108). Co-Authored-By: Claude Opus 4.8 --- .../components/layout/Header.tsx | 7 -- .../new_dashboard/HeaderUserMenu.test.tsx | 71 +++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 frontend/tests/unit/new_dashboard/HeaderUserMenu.test.tsx diff --git a/frontend/src/domains/new_dashboard/components/layout/Header.tsx b/frontend/src/domains/new_dashboard/components/layout/Header.tsx index d403888..e8a656e 100644 --- a/frontend/src/domains/new_dashboard/components/layout/Header.tsx +++ b/frontend/src/domains/new_dashboard/components/layout/Header.tsx @@ -392,13 +392,6 @@ export function Header({ > Meu Perfil - , +})); + +vi.mock("@/domains/new_dashboard/components/layout/MessageDetailModal", () => ({ + MessageDetailModal: () => null, +})); + +function openUserMenu() { + render(
); + fireEvent.click(screen.getByLabelText("Menu do usuário")); +} + +describe("Header - dropdown do usuário (PAV-108)", () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + it("AC1/AC4: não exibe a opção 'Segurança' no dropdown superior", () => { + openUserMenu(); + + expect( + screen.queryByRole("button", { name: "Segurança" }), + ).not.toBeInTheDocument(); + }); + + it("AC2: exibe a opção 'Ajuda' no dropdown superior", () => { + openUserMenu(); + + expect( + screen.getByRole("button", { name: "Ajuda" }), + ).toBeInTheDocument(); + }); + + it("AC3: 'Ajuda' navega para /ajuda (mesmo destino da Ajuda inferior)", () => { + openUserMenu(); + + fireEvent.click(screen.getByRole("button", { name: "Ajuda" })); + + expect(mockNavigate).toHaveBeenCalledWith("/ajuda"); + }); +});