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
5 changes: 5 additions & 0 deletions src/app/(app)/cadastros/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,8 @@ export async function addProjectParticipant(_prev: ActionState, formData: FormDa
export async function removeProjectParticipant(_prev: ActionState, formData: FormData): Promise<ActionState> {
return mutate((t) => t.projectMembers.remove(text(formData, "projectId"), text(formData, "userId")));
}

export async function setMemberProjects(_prev: ActionState, formData: FormData): Promise<ActionState> {
const projectIds = formData.getAll("projectId").filter((v): v is string => typeof v === "string");
return mutate((t) => t.projectMembers.setForMember(text(formData, "userId"), projectIds));
}
19 changes: 19 additions & 0 deletions src/app/(app)/cadastros/invite-members-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import { useClerk } from "@clerk/nextjs";
import { UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";

/**
* Invites are handled by Clerk's organization panel (ADR-033: no custom UI on Clerk's API, to keep the exit cheap).
* The panel lists members, sends e-mail invitations with a role (admin or member), shows the pending ones and revokes them.
*/
export function InviteMembersButton() {
const { openOrganizationProfile } = useClerk();
return (
<Button type="button" size="sm" onClick={() => openOrganizationProfile()}>
<UserPlus aria-hidden />
Convidar pessoas
</Button>
);
}
61 changes: 61 additions & 0 deletions src/app/(app)/cadastros/member-projects-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import { FolderCog } from "lucide-react";
import { Button } from "@/components/ui/button";
import { setMemberProjects } from "./actions";
import { EntityDialog } from "./entity-dialog";

export type AssignableProject = { id: string; name: string; color: string; clientName: string };

/** Ticks the active projects a member takes part in (admins never need this: they use every project). */
export function MemberProjectsDialog({
userId,
memberName,
projects,
selectedIds,
highlight,
}: {
userId: string;
memberName: string;
projects: AssignableProject[];
selectedIds: string[];
/** The member has no project yet: make the way out obvious. */
highlight: boolean;
}) {
// Plain loop instead of Map.groupBy: older iOS versions (the installed PWA) do not have it.
const byClient = new Map<string, AssignableProject[]>();
for (const project of projects) byClient.set(project.clientName, [...(byClient.get(project.clientName) ?? []), project]);
return (
<EntityDialog
title={`Projetos de ${memberName}`}
description="Marque os projetos em que a pessoa pode lançar horas. Os registros já feitos não mudam."
action={setMemberProjects}
hidden={{ userId }}
trigger={
<Button type="button" size="sm" variant={highlight ? "default" : "outline"}>
<FolderCog aria-hidden />
Projetos
</Button>
}
>
{(errors) => (
<div className="grid max-h-80 gap-3 overflow-y-auto pr-1">
{projects.length === 0 && <p className="text-sm text-muted-foreground">Não há projetos ativos. Crie um projeto primeiro.</p>}
{[...byClient].map(([client, list]) => (
<fieldset key={client} className="grid gap-1.5">
<legend className="mb-1 text-xs font-medium text-muted-foreground">{client}</legend>
{list.map((project) => (
<label key={project.id} className="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" name="projectId" value={project.id} defaultChecked={selectedIds.includes(project.id)} className="size-4 accent-primary" />
<span className="size-2 rounded-full" style={{ backgroundColor: project.color }} aria-hidden />
{project.name}
</label>
))}
</fieldset>
))}
{errors.userId && <p className="text-sm text-destructive">{errors.userId}</p>}
</div>
)}
</EntityDialog>
);
}
24 changes: 19 additions & 5 deletions src/app/(app)/cadastros/panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Badge } from "@/components/ui/badge";
import { ActionButton } from "./entity-dialog";
import { setOrganizationArchived, setProjectArchived } from "./actions";
import { OrganizationDialog, ProjectDialog, TagDialog } from "./entity-dialogs";
import { InviteMembersButton } from "./invite-members-button";
import { MemberProjectsDialog } from "./member-projects-dialog";
import type { Tenant } from "@/server/tenant";

type PanelProps = { tenant: Tenant; canEdit: boolean; includeArchived: boolean; basePath: string };
Expand Down Expand Up @@ -148,13 +150,16 @@ export async function TagsPanel({ tenant, canEdit, includeArchived, basePath }:
}

export async function MembersPanel({ tenant }: { tenant: Tenant }) {
const members = await tenant.projectMembers.overview();
const [members, assignable] = await Promise.all([tenant.projectMembers.overview(), tenant.projectMembers.assignableProjects()]);
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Pessoas do laboratório e os projetos em que participam. Convites e papéis (admin/membro) são geridos no seletor de organização, no topo; a participação
em projetos é ajustada na página de cada projeto.
</p>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="min-w-0 flex-1 basis-72 text-sm text-muted-foreground">
Pessoas do laboratório e os projetos em que participam. Quem entra por convite começa sem projetos: use &ldquo;Projetos&rdquo; para liberar. Em
&ldquo;Convidar pessoas&rdquo;, abra a aba Membros para convidar por e-mail, escolher admin ou membro e ver ou cancelar convites pendentes.
</p>
<InviteMembersButton />
</div>
<List>
{members.map((member) => {
const active = member.projects.filter((project) => !project.isArchived);
Expand Down Expand Up @@ -183,6 +188,15 @@ export async function MembersPanel({ tenant }: { tenant: Tenant }) {
))
)}
</div>
{member.role === "member" && (
<MemberProjectsDialog
userId={member.userId}
memberName={member.name}
projects={assignable}
selectedIds={active.map((project) => project.id)}
highlight={active.length === 0}
/>
)}
</Row>
);
})}
Expand Down
49 changes: 49 additions & 0 deletions src/server/project-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,55 @@ describe("project participation (access model)", () => {
expect(rows).toHaveLength(projects.length * 3);
});

describe("setting a member's projects in one go", () => {
const projectIdsOf = async (u: Who) => (await tAdmin.projectMembers.overview()).find((m) => m.userId === u.userId)?.projects.map((p) => p.id).sort() ?? [];

it("offers only active projects, and only to admins", async () => {
const org = await tAdmin.organizations.create({ name: "Cliente set" });
const old = await tAdmin.projects.create({ organizationId: org.id, name: "Arquivado set" });
await tAdmin.projects.update(old.id, { isArchived: true });
const offered = (await tAdmin.projectMembers.assignableProjects()).map((p) => p.id);
expect(offered).toContain(open.id);
expect(offered).not.toContain(old.id);
await expect(tAna.projectMembers.assignableProjects()).rejects.toBeInstanceOf(ForbiddenError);
expect((await tOther.projectMembers.assignableProjects()).map((p) => p.id)).not.toContain(open.id);
});

it("adds and removes, is idempotent, and keeps archived projects and logged hours", async () => {
const org = await tAdmin.organizations.create({ name: "Cliente set 2" });
const a = await tAdmin.projects.create({ organizationId: org.id, name: "A" });
const b = await tAdmin.projects.create({ organizationId: org.id, name: "B" });
const archived = await tAdmin.projects.create({ organizationId: org.id, name: "C arquivado" });
await tAdmin.projectMembers.add(archived.id, bia.userId);
await tAdmin.projects.update(archived.id, { isArchived: true });
const hours = (await db.select().from(timeEntries).where(eq(timeEntries.userId, bia.userId))).length;

await tAdmin.projectMembers.setForMember(bia.userId, [a.id, b.id]);
await tAdmin.projectMembers.setForMember(bia.userId, [a.id, b.id]);
expect(await projectIdsOf(bia)).toEqual(expect.arrayContaining([a.id, b.id, archived.id]));

await tAdmin.projectMembers.setForMember(bia.userId, [b.id]);
const after = await projectIdsOf(bia);
expect(after).toContain(b.id);
expect(after).toContain(archived.id); // archived memberships are not touched
expect(after).not.toContain(a.id);

await tAdmin.projectMembers.setForMember(bia.userId, []);
expect(await projectIdsOf(bia)).toEqual([archived.id]);
expect((await db.select().from(timeEntries).where(eq(timeEntries.userId, bia.userId))).length).toBe(hours);
});

it("refuses admins, removed members, other workspaces' people and projects, and non-admin callers", async () => {
await expect(tAdmin.projectMembers.setForMember(admin.userId, [open.id])).rejects.toBeInstanceOf(ValidationError);
await expect(tAdmin.projectMembers.setForMember(gone.userId, [open.id])).rejects.toBeInstanceOf(NotFoundError);
await expect(tAdmin.projectMembers.setForMember(other.userId, [open.id])).rejects.toBeInstanceOf(NotFoundError);
await expect(tAdmin.projectMembers.setForMember(ana.userId, ["00000000-0000-4000-8000-000000000000"])).rejects.toBeInstanceOf(NotFoundError);
const foreignProject = await tOther.projects.create({ organizationId: (await tOther.organizations.create({ name: "Estrangeiro" })).id, name: "Fora" });
await expect(tAdmin.projectMembers.setForMember(ana.userId, [foreignProject.id])).rejects.toBeInstanceOf(NotFoundError);
await expect(tAna.projectMembers.setForMember(ana.userId, [open.id])).rejects.toBeInstanceOf(ForbiddenError);
});
});

describe("members overview", () => {
it("lists active members with their projects, and leaves out removed ones", async () => {
const overview = await tAdmin.projectMembers.overview();
Expand Down
46 changes: 45 additions & 1 deletion src/server/project-members.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { and, asc, eq, inArray, isNull, notInArray, type SQL, sql } from "drizzle-orm";
import { organizations, projectMembers, projects, users, workspaceMembers } from "@/db/schema";
import { displayName } from "@/lib/display-name";
import { ForbiddenError, NotFoundError } from "./errors";
import { ForbiddenError, NotFoundError, ValidationError } from "./errors";
import type { TenantContext, TenantDb } from "./tenant";

export type MemberOverview = Participant & {
Expand Down Expand Up @@ -104,6 +104,50 @@ export function createProjectMembers(db: TenantDb, ctx: TenantContext, deps: { g
}));
},

/** Active (not archived) projects with their client, to offer in the "projects of this member" picker. */
async assignableProjects(): Promise<{ id: string; name: string; color: string; clientName: string }[]> {
requireAdmin();
return db
.select({ id: projects.id, name: projects.name, color: projects.color, clientName: organizations.name })
.from(projects)
.innerJoin(organizations, eq(organizations.id, projects.organizationId))
.where(and(eq(projects.workspaceId, ws), eq(projects.isArchived, false)))
.orderBy(asc(organizations.name), asc(projects.name));
},

/**
* Sets, in one go, which active projects a member takes part in. Archived projects are left as they are, and hours
* already logged are never touched. Admins are unrestricted, so there is nothing to set for them. Not atomic
* (neon-http): additions run first, so an interruption never leaves the person with fewer projects than before.
*/
async setForMember(userId: string, projectIds: string[]): Promise<void> {
requireAdmin();
const [member] = await activeMembers(eq(users.id, userId));
if (!member) throw new NotFoundError("Member");
if (member.role === "admin") throw new ValidationError({ userId: "Administradores já têm acesso a todos os projetos." });

const active = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.workspaceId, ws), eq(projects.isArchived, false)));
const activeIds = active.map((p) => p.id);
const wanted = [...new Set(projectIds)];
if (wanted.some((id) => !activeIds.includes(id))) throw new NotFoundError("Project");

if (wanted.length > 0) {
await db
.insert(projectMembers)
.values(wanted.map((projectId) => ({ workspaceId: ws, projectId, userId })))
.onConflictDoNothing();
}
const unwanted = activeIds.filter((id) => !wanted.includes(id));
if (unwanted.length > 0) {
await db
.delete(projectMembers)
.where(and(eq(projectMembers.workspaceId, ws), eq(projectMembers.userId, userId), inArray(projectMembers.projectId, unwanted)));
}
},

async add(projectId: string, userId: string): Promise<void> {
requireAdmin();
await deps.getProject(projectId);
Expand Down
Loading