Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
95b8e69
feat: enhance tests for useJobsData and auth service with new scenarios
pedrosilvaadev Jul 27, 2026
eb34cd7
docs(PAV-7): spec de vincular conta social e gerenciar conexões
Jul 27, 2026
a2e3a04
feat: enhance tests for useJobsData and auth service with new scenari…
pedrosilvaadev Jul 27, 2026
8cd92b9
docs(PAV-7): plano de implementação de gerenciar conexões
Jul 27, 2026
36b0355
feat(PAV-7): vincular provider a usuário com checagem de conflito
Jul 27, 2026
d84baf6
feat(PAV-7): desconectar provider validando último método de login
Jul 27, 2026
02f7e45
feat(PAV-7): listar conexões do usuário com status por provider
Jul 27, 2026
ed52b23
feat(PAV-7): endpoints GET/DELETE de conexões do usuário
Jul 27, 2026
7a5071a
feat(PAV-7): modo vínculo no callback OAuth (conectar provider logado)
Jul 27, 2026
37045f1
feat(PAV-7): client de API de conexões (frontend)
Jul 27, 2026
4555c9d
feat(PAV-7): tela Gerenciar conexões e rota /perfil/conexoes
Jul 27, 2026
34d9294
fix(PAV-7): disconnect atômico com lock + hardening de erros e testes
Jul 27, 2026
0401554
feat(PAV-7): conexoes como secao dentro do perfil
Jul 27, 2026
1bde02f
Merge remote-tracking branch 'upstream/develop' into feature/pav-7-ge…
Jul 27, 2026
eff7cfc
test(PAV-7): cobre ConnectionsForm e restaura threshold de branches
Jul 27, 2026
c72bb26
fix: substituir opção "Segurança" por "Ajuda" no dropdown superior
Jul 27, 2026
d1f7fdf
PAV-108: Substituir opção "Segurança" por "Ajuda" no dropdown superio…
Benevanio Jul 27, 2026
ac1d9f8
PAV-7: Vincular conta social a usuário local + merge de contas (#202)
Benevanio Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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?linked=${params.provider}`,
);
} catch (linkError) {
const code =
linkError instanceof AppError && linkError.code === "CONFLICT"
? "provider_already_linked"
: "link_failed";
return res.redirect(`${frontendUrl}/perfil?error=${code}`);
}
}

const result = await this.authService.handleCallback({
...params,
callbackUrl,
Expand Down
28 changes: 28 additions & 0 deletions backend/src/modules/auth/connections.controller.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
48 changes: 48 additions & 0 deletions backend/src/modules/users/functions/disconnectProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 };

async function runDisconnect(
{ userId, provider }: DisconnectParams,
tx: DB,
): Promise<void> {
const userAccounts = await tx
.select()
.from(accounts)
.where(eq(accounts.userId, userId))
.for("update");

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)));
}

export async function disconnectProvider(
params: DisconnectParams,
tx?: DB,
): Promise<void> {
if (tx) return runDisconnect(params, tx);
return db.transaction((t) => runDisconnect(params, t as unknown as DB));
}
28 changes: 28 additions & 0 deletions backend/src/modules/users/functions/linkProviderToUser.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
42 changes: 42 additions & 0 deletions backend/src/modules/users/functions/listUserConnections.ts
Original file line number Diff line number Diff line change
@@ -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<UserConnections> {
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 };
}
11 changes: 11 additions & 0 deletions backend/src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions backend/tests/integration/routes/connections.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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");
});

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();
});
});
Loading
Loading