Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 21 additions & 10 deletions src/server/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ export function createAuth({
? { github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, requireEmailVerification: true } }
: {};

/** Logical removal (LGPD): the person loses access but their time entries stay. Idempotent. */
async function markMemberRemoved(authOrgId: string, authUserId: string) {
const [ws] = await db.select({ id: workspaces.id }).from(workspaces).where(eq(workspaces.authOrgId, authOrgId));
const [appUser] = await db.select({ id: users.id }).from(users).where(eq(users.authId, authUserId));
if (!ws || !appUser) return;
const now = new Date();
await db
.update(workspaceMembers)
.set({ removedAt: now, updatedAt: now })
.where(and(eq(workspaceMembers.workspaceId, ws.id), eq(workspaceMembers.userId, appUser.id), isNull(workspaceMembers.removedAt)));
}

return betterAuth({
baseURL,
secret: env.BETTER_AUTH_SECRET,
Expand Down Expand Up @@ -67,6 +79,14 @@ export function createAuth({
throw new APIError("TOO_MANY_REQUESTS", { message: "Muitos códigos pedidos para este e-mail. Tente de novo mais tarde." });
}
}),
// `/organization/leave` deletes the auth member but, unlike `remove-member`, runs no organization hook (COMP-008): without
// this the person would stay "active" in the domain tables (project candidates, anonymization plan, reports).
after: createAuthMiddleware(async (ctx) => {
if (ctx.path !== "/organization/leave") return;
const left = ctx.context.returned as { userId?: unknown; organizationId?: unknown } | Error | undefined;
if (!left || left instanceof Error || typeof left.userId !== "string" || typeof left.organizationId !== "string") return;
await markMemberRemoved(left.organizationId, left.userId);
}),
},
databaseHooks: {
user: {
Expand Down Expand Up @@ -125,16 +145,7 @@ export function createAuth({
},
organizationHooks: {
// Logical removal (LGPD): the member loses access but their time entries stay.
afterRemoveMember: async ({ user, organization: org }) => {
const [ws] = await db.select({ id: workspaces.id }).from(workspaces).where(eq(workspaces.authOrgId, org.id));
const [appUser] = await db.select({ id: users.id }).from(users).where(eq(users.authId, user.id));
if (!ws || !appUser) return;
const now = new Date();
await db
.update(workspaceMembers)
.set({ removedAt: now, updatedAt: now })
.where(and(eq(workspaceMembers.workspaceId, ws.id), eq(workspaceMembers.userId, appUser.id), isNull(workspaceMembers.removedAt)));
},
afterRemoveMember: async ({ user, organization: org }) => markMemberRemoved(org.id, user.id),
// Deleting an organization archives its workspace: every record stays, access is blocked (ADR-034). Terminal.
afterDeleteOrganization: async ({ organization: org }) => {
const now = new Date();
Expand Down
78 changes: 78 additions & 0 deletions src/server/auth/member-leave.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { and, eq } from "drizzle-orm";
import { beforeAll, describe, expect, it } from "vitest";
import type { Db } from "@/db";
import { users, workspaceMembers, workspaces } from "@/db/schema";
import { createTestDb, type TestDb } from "@/test/db";
import { createAuth } from "./auth";
import { getAuthEnv } from "./env";
import { resolveWorkspaceContext } from "./workspace-context";

/** COMP-008: leaving a workspace on one's own must retire the domain membership, like removal by an admin does. */
describe("voluntary leave", () => {
let db: TestDb;
let auth: ReturnType<typeof createAuth>;
const mails: { text: string }[] = [];
const asDb = () => db as unknown as Db;

beforeAll(async () => {
db = await createTestDb();
auth = createAuth({
db: asDb(),
nextJsCookies: false,
env: getAuthEnv({ NODE_ENV: "test", BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-123" }),
send: async (m) => void mails.push(m),
});
});

async function signIn(email: string) {
mails.length = 0;
await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } });
await new Promise((r) => setTimeout(r, 20));
const otp = /(\d{6})/.exec(mails[0].text)![1];
const res = await auth.api.signInEmailOTP({ body: { email, otp }, returnHeaders: true });
return { headers: new Headers({ cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") }), user: res.response.user };
}

const sessionOf = (u: { id: string; email: string }, orgId: string) => ({
user: { id: u.id, email: u.email, name: "Pessoa", image: null },
session: { activeOrganizationId: orgId },
});

const removedAt = async (orgId: string, authUserId: string) => {
const [row] = await db
.select({ removedAt: workspaceMembers.removedAt })
.from(workspaceMembers)
.innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId))
.innerJoin(users, eq(users.id, workspaceMembers.userId))
.where(and(eq(workspaces.authOrgId, orgId), eq(users.authId, authUserId)));
return row.removedAt;
};

it("marks the domain membership as removed, and only for the person who left", async () => {
const owner = await signIn("leave-owner@example.com");
const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Leave", slug: "leave" } });
const invitation = await auth.api.createInvitation({ headers: owner.headers, body: { email: "leaver@example.com", role: "member", organizationId: org.id } });
const leaver = await signIn("leaver@example.com");
await auth.api.acceptInvitation({ headers: leaver.headers, body: { invitationId: invitation.id } });
await auth.api.setActiveOrganization({ headers: leaver.headers, body: { organizationId: org.id } });

// both people exist in the domain tables (first access)
expect((await resolveWorkspaceContext(asDb(), sessionOf(owner.user, org.id))).kind).toBe("ok");
expect((await resolveWorkspaceContext(asDb(), sessionOf(leaver.user, org.id))).kind).toBe("ok");
expect(await removedAt(org.id, leaver.user.id)).toBeNull();

await auth.api.leaveOrganization({ headers: leaver.headers, body: { organizationId: org.id } });

expect(await removedAt(org.id, leaver.user.id)).toBeInstanceOf(Date);
expect(await removedAt(org.id, owner.user.id)).toBeNull();
expect((await resolveWorkspaceContext(asDb(), sessionOf(leaver.user, org.id))).kind).toBe("no-membership");
});

it("a refused leave (sole owner) changes nothing", async () => {
const owner = await signIn("sole-owner@example.com");
const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Sole", slug: "sole" } });
await resolveWorkspaceContext(asDb(), sessionOf(owner.user, org.id));
await expect(auth.api.leaveOrganization({ headers: owner.headers, body: { organizationId: org.id } })).rejects.toThrow();
expect(await removedAt(org.id, owner.user.id)).toBeNull();
});
});
Loading