diff --git a/src/app/(app)/cadastros/actions.ts b/src/app/(app)/cadastros/actions.ts index ab23e39..ef5d7ca 100644 --- a/src/app/(app)/cadastros/actions.ts +++ b/src/app/(app)/cadastros/actions.ts @@ -68,3 +68,8 @@ export async function addProjectParticipant(_prev: ActionState, formData: FormDa export async function removeProjectParticipant(_prev: ActionState, formData: FormData): Promise { return mutate((t) => t.projectMembers.remove(text(formData, "projectId"), text(formData, "userId"))); } + +export async function setMemberProjects(_prev: ActionState, formData: FormData): Promise { + const projectIds = formData.getAll("projectId").filter((v): v is string => typeof v === "string"); + return mutate((t) => t.projectMembers.setForMember(text(formData, "userId"), projectIds)); +} diff --git a/src/app/(app)/cadastros/invite-members-button.tsx b/src/app/(app)/cadastros/invite-members-button.tsx new file mode 100644 index 0000000..31e7b6f --- /dev/null +++ b/src/app/(app)/cadastros/invite-members-button.tsx @@ -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 ( + + ); +} diff --git a/src/app/(app)/cadastros/member-projects-dialog.tsx b/src/app/(app)/cadastros/member-projects-dialog.tsx new file mode 100644 index 0000000..d9579c0 --- /dev/null +++ b/src/app/(app)/cadastros/member-projects-dialog.tsx @@ -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(); + for (const project of projects) byClient.set(project.clientName, [...(byClient.get(project.clientName) ?? []), project]); + return ( + + ); +} diff --git a/src/app/(app)/cadastros/panels.tsx b/src/app/(app)/cadastros/panels.tsx index 72db50c..8f71495 100644 --- a/src/app/(app)/cadastros/panels.tsx +++ b/src/app/(app)/cadastros/panels.tsx @@ -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 }; @@ -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 (
-

- 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. -

+
+

+ Pessoas do laboratório e os projetos em que participam. Quem entra por convite começa sem projetos: use “Projetos” para liberar. Em + “Convidar pessoas”, abra a aba Membros para convidar por e-mail, escolher admin ou membro e ver ou cancelar convites pendentes. +

+ +
{members.map((member) => { const active = member.projects.filter((project) => !project.isArchived); @@ -183,6 +188,15 @@ export async function MembersPanel({ tenant }: { tenant: Tenant }) { )) )}
+ {member.role === "member" && ( + project.id)} + highlight={active.length === 0} + /> + )} ); })} diff --git a/src/server/project-access.test.ts b/src/server/project-access.test.ts index aee303e..fdb99e8 100644 --- a/src/server/project-access.test.ts +++ b/src/server/project-access.test.ts @@ -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(); diff --git a/src/server/project-members.ts b/src/server/project-members.ts index 173ea75..922f491 100644 --- a/src/server/project-members.ts +++ b/src/server/project-members.ts @@ -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 & { @@ -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 { + 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 { requireAdmin(); await deps.getProject(projectId);