diff --git a/app/globals.css b/app/globals.cssindex 80a8cd2..a18543b 100644--- a/app/globals.css+++ b/app/globals.css@@ -280,6 +280,37 @@ td.actions { font-size: 0.8rem; color: var(--muted); }+.add-project {+ display: flex;+ align-items: center;+ gap: 0.5rem;+ margin-top: 0.25rem;+ font-size: 0.875rem;+}+.add-project select {+ padding: 0.2rem 0.4rem;+}+.guest-form {+ max-width: 420px;+}+.guest-form fieldset {+ display: grid;+ gap: 0.25rem;+ margin: 0;+ border: 1px solid var(--line);+ border-radius: 6px;+}+.guest-form legend {+ font-size: 0.9rem;+ color: var(--muted);+}+.guest-form fieldset label {+ display: flex;+ align-items: center;+ gap: 0.5rem;+ color: var(--text);+ font-size: 1rem;+} @media (max-width: 720px) { .board {diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsxindex b5acaa3..5f02fa5 100644--- a/app/invite/[token]/page.tsx+++ b/app/invite/[token]/page.tsx@@ -42,7 +42,11 @@ export default async function Invite(props: PageProps<"/invite/[token]">) { return ( <main className="narrow"> <h1>Join {invitation.workspace_name}</h1>- <p>You'll be able to see and work on every project in the workspace.</p>+ {invitation.role === "guest" ? (+ <p>You'll be able to read and comment on tasks in {invitation.projects.join(", ")}.</p>+ ) : (+ <p>You'll be able to see and work on every project in the workspace.</p>+ )} <form action={accept}> <button type="submit">Accept invitation</button> </form>diff --git a/app/w/[workspace]/actions.ts b/app/w/[workspace]/actions.tsindex 19a41fd..172170c 100644--- a/app/w/[workspace]/actions.ts+++ b/app/w/[workspace]/actions.ts@@ -5,9 +5,9 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { authorize, workspaceAccess } from "@/lib/access"; import { email, text } from "@/lib/forms";-import { invite, membersOf, ownerCount, removeMember, revokeInvitation } from "@/lib/members";-import { canRemoveMember } from "@/lib/permissions";-import { archiveProject, createProject, projectIn } from "@/lib/projects";+import { addGuestProject, invite, membersOf, ownerCount, removeMember, revokeInvitation } from "@/lib/members";+import { can, canRemoveMember } from "@/lib/permissions";+import { archiveProject, createProject, projectIn, projectsIn } from "@/lib/projects"; import { addComment, createTask, moveTask, statuses, taskIn, updateTask, type Status } from "@/lib/tasks"; function status(form: FormData): Status {@@ -33,7 +33,7 @@ export async function archiveProjectAction(form: FormData) { export async function createTaskAction(form: FormData) { const access = await workspaceAccess(text(form, "workspace")); authorize(access, "task.create");- const project = await projectIn(access.workspace.id, text(form, "project"));+ const project = await projectIn(access, text(form, "project")); if (!project || project.archived_at) throw new Error("That project doesn't take new tasks."); await createTask(project.id, access.user.id, text(form, "title", { max: 160 })); revalidatePath(`/w/${access.workspace.slug}/projects/${project.id}`);@@ -42,7 +42,7 @@ export async function createTaskAction(form: FormData) { export async function moveTaskAction(form: FormData) { const access = await workspaceAccess(text(form, "workspace")); authorize(access, "task.edit");- const task = await taskIn(access.workspace.id, text(form, "task"));+ const task = await taskIn(access, text(form, "task")); if (!task) throw new Error("Task not found."); await moveTask(task.id, status(form)); revalidatePath(`/w/${access.workspace.slug}/projects/${task.project_id}`);@@ -51,12 +51,12 @@ export async function moveTaskAction(form: FormData) { export async function updateTaskAction(form: FormData) { const access = await workspaceAccess(text(form, "workspace")); authorize(access, "task.edit");- const task = await taskIn(access.workspace.id, text(form, "task"));+ const task = await taskIn(access, text(form, "task")); if (!task) throw new Error("Task not found."); const assigneeId = text(form, "assignee", { optional: true }) || null;- // Tasks can only be assigned to people in the workspace.- if (assigneeId && !(await membersOf(access.workspace.id)).some((member) => member.user_id === assigneeId)) {- throw new Error("That person isn't in this workspace.");+ // Tasks can only be assigned to people in the workspace who can work on them, so not to guests.+ if (assigneeId && !(await membersOf(access.workspace.id)).some((member) => member.user_id === assigneeId && can(member.role, "task.edit"))) {+ throw new Error("That person can't be assigned tasks in this workspace."); } await updateTask(task.id, { title: text(form, "title", { max: 160 }),@@ -70,7 +70,7 @@ export async function updateTaskAction(form: FormData) { export async function addCommentAction(form: FormData) { const access = await workspaceAccess(text(form, "workspace")); authorize(access, "task.comment");- const task = await taskIn(access.workspace.id, text(form, "task"));+ const task = await taskIn(access, text(form, "task")); if (!task) throw new Error("Task not found."); await addComment(task.id, access.user.id, text(form, "body", { max: 5000 })); revalidatePath(`/w/${access.workspace.slug}/tasks/${task.id}`);@@ -83,7 +83,26 @@ export async function inviteAction(form: FormData) { if ((await membersOf(access.workspace.id)).some((member) => member.email.toLowerCase() === address)) { throw new Error(`${address} is already in this workspace.`); }- await invite(access.workspace.id, address, access.user.id);+ if (form.get("role") === "guest") {+ const projectIds = form.getAll("project").map(String);+ if (projectIds.length === 0) throw new Error("Pick at least one project for the guest.");+ const open = new Set((await projectsIn(access)).filter((project) => !project.archived_at).map((project) => project.id));+ if (!projectIds.every((id) => open.has(id))) throw new Error("Unknown project.");+ await invite(access.workspace.id, address, access.user.id, "guest", projectIds);+ } else {+ await invite(access.workspace.id, address, access.user.id, "member");+ }+ revalidatePath(`/w/${access.workspace.slug}/members`);+}++export async function addGuestProjectAction(form: FormData) {+ const access = await workspaceAccess(text(form, "workspace"));+ authorize(access, "member.invite");+ const guest = (await membersOf(access.workspace.id)).find((member) => member.user_id === text(form, "user"));+ if (guest?.role !== "guest") throw new Error("That person isn't a guest in this workspace.");+ const project = await projectIn(access, text(form, "project"));+ if (!project || project.archived_at) throw new Error("Unknown project.");+ await addGuestProject(access.workspace.id, guest.user_id, project.id); revalidatePath(`/w/${access.workspace.slug}/members`); } diff --git a/app/w/[workspace]/layout.tsx b/app/w/[workspace]/layout.tsxindex 40e5509..a7d2684 100644--- a/app/w/[workspace]/layout.tsx+++ b/app/w/[workspace]/layout.tsx@@ -1,11 +1,12 @@ import Link from "next/link"; import { workspaceAccess } from "@/lib/access";+import { can } from "@/lib/permissions"; import { workspacesOf } from "@/lib/workspaces"; import { signOut } from "../../sign-in/actions"; export default async function WorkspaceLayout(props: LayoutProps<"/w/[workspace]">) { const { workspace: slug } = await props.params;- const { user, workspace } = await workspaceAccess(slug);+ const { user, workspace, role } = await workspaceAccess(slug); const workspaces = await workspacesOf(user.id); return ( <div className="shell">@@ -20,7 +21,7 @@ export default async function WorkspaceLayout(props: LayoutProps<"/w/[workspace] {other.name} </Link> ))}- <Link href={`/w/${workspace.slug}/members`}>Members</Link>+ {can(role, "member.view") && <Link href={`/w/${workspace.slug}/members`}>Members</Link>} </nav> <form action={signOut} className="account"> <span>{user.name}</span>diff --git a/app/w/[workspace]/members/page.tsx b/app/w/[workspace]/members/page.tsxindex 9aa4360..77fcf4d 100644--- a/app/w/[workspace]/members/page.tsx+++ b/app/w/[workspace]/members/page.tsx@@ -1,17 +1,26 @@ import { headers } from "next/headers";+import { notFound } from "next/navigation"; import { workspaceAccess } from "@/lib/access";-import { membersOf, ownerCount, pendingInvitations } from "@/lib/members";-import { can, canRemoveMember } from "@/lib/permissions";-import { inviteAction, removeMemberAction, revokeInvitationAction } from "../actions";+import { membersOf, ownerCount, pendingInvitations, type Member } from "@/lib/members";+import { can, canRemoveMember, type Role } from "@/lib/permissions";+import { projectsIn, type Project } from "@/lib/projects";+import { addGuestProjectAction, inviteAction, removeMemberAction, revokeInvitationAction } from "../actions";++const roleLabels: Record<Role, string> = { owner: "Owner", member: "Member", guest: "Guest" }; export default async function Members(props: PageProps<"/w/[workspace]/members">) { const { workspace: slug } = await props.params; const access = await workspaceAccess(slug);- const [members, invitations, owners] = await Promise.all([+ // Guests don't see who else is in the workspace, or which other projects exist.+ if (!can(access.role, "member.view")) notFound();+ const canInvite = can(access.role, "member.invite");+ const [members, invitations, owners, projects] = await Promise.all([ membersOf(access.workspace.id),- can(access.role, "member.invite") ? pendingInvitations(access.workspace.id) : [],+ canInvite ? pendingInvitations(access.workspace.id) : [], ownerCount(access.workspace.id),+ canInvite ? projectsIn(access) : [], ]);+ const openProjects = projects.filter((project) => !project.archived_at); const host = (await headers()).get("host"); const origin = `${host?.startsWith("localhost") ? "http" : "https"}://${host}`; @@ -27,7 +36,16 @@ export default async function Members(props: PageProps<"/w/[workspace]/members"> <br /> <span className="hint">{member.email}</span> </td>- <td>{member.role === "owner" ? "Owner" : "Member"}</td>+ <td>+ {roleLabels[member.role]}+ {member.role === "guest" && (+ <>+ <br />+ <span className="hint">{member.projects.map((project) => project.name).join(", ")}</span>+ {canInvite && <AddGuestProject slug={slug} guest={member} projects={openProjects} />}+ </>+ )}+ </td> <td className="actions"> {canRemoveMember(access.role, member, owners) && ( <form action={removeMemberAction}>@@ -44,7 +62,7 @@ export default async function Members(props: PageProps<"/w/[workspace]/members"> </tbody> </table> - {can(access.role, "member.invite") && (+ {canInvite && ( <section> <h2>Invite someone</h2> <p className="hint">They'll join as a member and see every project in {access.workspace.name}.</p>@@ -53,31 +71,78 @@ export default async function Members(props: PageProps<"/w/[workspace]/members"> <input type="email" name="email" placeholder="[email protected]" aria-label="Email" required /> <button type="submit">Create invitation</button> </form>- {invitations.length > 0 && (- <>- <h3>Waiting to accept</h3>- <p className="hint">Send each person their link. It works once, for that address.</p>- <ul className="plain">- {invitations.map((invitation) => (- <li key={invitation.id} className="invitation">- <span>- {invitation.email} <span className="hint">invited by {invitation.invited_by_name}</span>- </span>- <input readOnly value={`${origin}/invite/${invitation.token}`} aria-label={`Invitation link for ${invitation.email}`} />- <form action={revokeInvitationAction}>- <input type="hidden" name="workspace" value={slug} />- <input type="hidden" name="invitation" value={invitation.id} />- <button type="submit" className="link danger">- Revoke- </button>- </form>- </li>- ))}- </ul>- </>- )}+ </section>+ )}++ {canInvite && openProjects.length > 0 && (+ <section>+ <h2>Invite a guest</h2>+ <p className="hint">Guests see only the projects you pick. They can read tasks and comment, but can't change the board.</p>+ <form action={inviteAction} className="stack guest-form">+ <input type="hidden" name="workspace" value={slug} />+ <input type="hidden" name="role" value="guest" />+ <input type="email" name="email" placeholder="[email protected]" aria-label="Guest email" required />+ <fieldset>+ <legend>Projects</legend>+ {openProjects.map((project) => (+ <label key={project.id}>+ <input type="checkbox" name="project" value={project.id} /> {project.name}+ </label>+ ))}+ </fieldset>+ <button type="submit">Create guest invitation</button>+ </form>+ </section>+ )}++ {invitations.length > 0 && (+ <section>+ <h2>Waiting to accept</h2>+ <p className="hint">Send each person their link. It works once, for that address.</p>+ <ul className="plain">+ {invitations.map((invitation) => (+ <li key={invitation.id} className="invitation">+ <span>+ {invitation.email}{" "}+ <span className="hint">+ invited by {invitation.invited_by_name}+ {invitation.role === "guest" && ` as a guest on ${invitation.projects.join(", ")}`}+ </span>+ </span>+ <input readOnly value={`${origin}/invite/${invitation.token}`} aria-label={`Invitation link for ${invitation.email}`} />+ <form action={revokeInvitationAction}>+ <input type="hidden" name="workspace" value={slug} />+ <input type="hidden" name="invitation" value={invitation.id} />+ <button type="submit" className="link danger">+ Revoke+ </button>+ </form>+ </li>+ ))}+ </ul> </section> )} </> ); }++function AddGuestProject({ slug, guest, projects }: { slug: string; guest: Member; projects: Project[] }) {+ const addable = projects.filter((project) => !guest.projects.some((on) => on.id === project.id));+ if (addable.length === 0) return null;+ return (+ <form action={addGuestProjectAction} className="add-project">+ <input type="hidden" name="workspace" value={slug} />+ <input type="hidden" name="user" value={guest.user_id} />+ <select name="project" aria-label={`Add ${guest.name} to a project`}>+ {addable.map((project) => (+ <option key={project.id} value={project.id}>+ {project.name}+ </option>+ ))}+ </select>+ <button type="submit" className="link">+ Add+ </button>+ </form>+ );+}diff --git a/app/w/[workspace]/page.tsx b/app/w/[workspace]/page.tsxindex 13488f4..4b99d50 100644--- a/app/w/[workspace]/page.tsx+++ b/app/w/[workspace]/page.tsx@@ -7,7 +7,7 @@ import { createProjectAction } from "./actions"; export default async function Projects(props: PageProps<"/w/[workspace]">) { const { workspace: slug } = await props.params; const access = await workspaceAccess(slug);- const projects = await projectsIn(access.workspace.id);+ const projects = await projectsIn(access); const active = projects.filter((project) => !project.archived_at); const archived = projects.filter((project) => project.archived_at); diff --git a/app/w/[workspace]/projects/[project]/page.tsx b/app/w/[workspace]/projects/[project]/page.tsxindex 7f1658d..9a3c47f 100644--- a/app/w/[workspace]/projects/[project]/page.tsx+++ b/app/w/[workspace]/projects/[project]/page.tsx@@ -9,7 +9,7 @@ import { archiveProjectAction, createTaskAction, moveTaskAction } from "../../ac export default async function Board(props: PageProps<"/w/[workspace]/projects/[project]">) { const { workspace: slug, project: projectId } = await props.params; const access = await workspaceAccess(slug);- const project = await projectIn(access.workspace.id, projectId);+ const project = await projectIn(access, projectId); if (!project) notFound(); const tasks = await tasksIn(project.id); const canEdit = can(access.role, "task.edit") && !project.archived_at;diff --git a/app/w/[workspace]/tasks/[task]/page.tsx b/app/w/[workspace]/tasks/[task]/page.tsxindex 860d13c..3d96770 100644--- a/app/w/[workspace]/tasks/[task]/page.tsx+++ b/app/w/[workspace]/tasks/[task]/page.tsx@@ -9,10 +9,11 @@ import { addCommentAction, updateTaskAction } from "../../actions"; export default async function TaskPage(props: PageProps<"/w/[workspace]/tasks/[task]">) { const { workspace: slug, task: taskId } = await props.params; const access = await workspaceAccess(slug);- const task = await taskIn(access.workspace.id, taskId);+ const task = await taskIn(access, taskId); if (!task) notFound(); const [comments, members] = await Promise.all([commentsOn(task.id), membersOf(access.workspace.id)]); const canEdit = can(access.role, "task.edit");+ const assignees = members.filter((member) => can(member.role, "task.edit")); return ( <>@@ -39,7 +40,7 @@ export default async function TaskPage(props: PageProps<"/w/[workspace]/tasks/[t Assignee <select name="assignee" defaultValue={task.assignee_id ?? ""}> <option value="">Unassigned</option>- {members.map((member) => (+ {assignees.map((member) => ( <option key={member.user_id} value={member.user_id}> {member.name} </option>diff --git a/lib/members.ts b/lib/members.tsindex d86381e..98cbfc2 100644--- a/lib/members.ts+++ b/lib/members.ts@@ -2,42 +2,64 @@ import { randomBytes } from "node:crypto"; import { one, query, transaction } from "./db"; import type { Role } from "./permissions"; -export type Member = { user_id: string; name: string; email: string; role: Role; joined_at: Date };-export type Invitation = { id: string; email: string; token: string; invited_by_name: string; created_at: Date };+// projects lists the projects a guest is on; it's empty for owners and members, who see them all.+export type Member = { user_id: string; name: string; email: string; role: Role; projects: { id: string; name: string }[]; joined_at: Date };+export type Invitation = { id: string; email: string; token: string; role: Role; projects: string[]; invited_by_name: string; created_at: Date }; export function membersOf(workspaceId: string): Promise<Member[]> { return query<Member>(- `select m.user_id, u.name, u.email, m.role, m.created_at as joined_at from memberships m- join users u on u.id = m.user_id where m.workspace_id = $1- order by m.role = 'owner' desc, u.name`,+ `select m.user_id, u.name, u.email, m.role, m.created_at as joined_at,+ coalesce((select json_agg(json_build_object('id', p.id, 'name', p.name) order by p.name)+ from project_guests g join projects p on p.id = g.project_id+ where g.workspace_id = m.workspace_id and g.user_id = m.user_id), '[]') as projects+ from memberships m join users u on u.id = m.user_id where m.workspace_id = $1+ order by m.role = 'owner' desc, m.role = 'guest', u.name`, [workspaceId], ); } export function pendingInvitations(workspaceId: string): Promise<Invitation[]> { return query<Invitation>(- `select i.id, i.email, i.token, u.name as invited_by_name, i.created_at from invitations i- join users u on u.id = i.invited_by where i.workspace_id = $1 and i.accepted_at is null+ `select i.id, i.email, i.token, i.role, u.name as invited_by_name, i.created_at,+ array(select p.name from invitation_projects ip join projects p on p.id = ip.project_id+ where ip.invitation_id = i.id order by p.name) as projects+ from invitations i join users u on u.id = i.invited_by where i.workspace_id = $1 and i.accepted_at is null order by i.created_at desc`, [workspaceId], ); } -// Returns the token for the invitation link. Inviting the same address twice reuses the open invitation.-export async function invite(workspaceId: string, email: string, invitedBy: string): Promise<string> {- const open = await one<{ token: string }>(- "select token from invitations where workspace_id = $1 and lower(email) = lower($2) and accepted_at is null",- [workspaceId, email],- );- if (open) return open.token;- const token = randomBytes(24).toString("base64url");- await query("insert into invitations (workspace_id, email, token, invited_by) values ($1, $2, $3, $4)", [workspaceId, email, token, invitedBy]);- return token;+// Returns the token for the invitation link. Inviting the same address twice reuses the open invitation,+// with the role and projects of the latest one, so a link that's already been sent keeps working.+export function invite(workspaceId: string, email: string, invitedBy: string, role: "member" | "guest", projectIds: string[] = []): Promise<string> {+ return transaction(async (client) => {+ const open = await client.query<{ id: string; token: string }>(+ "select id, token from invitations where workspace_id = $1 and lower(email) = lower($2) and accepted_at is null for update",+ [workspaceId, email],+ );+ let invitation = open.rows[0];+ if (invitation) {+ await client.query("update invitations set role = $2 where id = $1", [invitation.id, role]);+ await client.query("delete from invitation_projects where invitation_id = $1", [invitation.id]);+ } else {+ const token = randomBytes(24).toString("base64url");+ invitation = (+ await client.query<{ id: string; token: string }>(+ "insert into invitations (workspace_id, email, token, invited_by, role) values ($1, $2, $3, $4, $5) returning id, token",+ [workspaceId, email, token, invitedBy, role],+ )+ ).rows[0];+ }+ await client.query("insert into invitation_projects (invitation_id, project_id) select $1, unnest($2::uuid[])", [invitation.id, projectIds]);+ return invitation.token;+ }); } export function invitationByToken(token: string) {- return one<{ id: string; email: string; workspace_id: string; workspace_name: string; workspace_slug: string; accepted_at: Date | null }>(- `select i.id, i.email, i.workspace_id, w.name as workspace_name, w.slug as workspace_slug, i.accepted_at+ return one<{ id: string; email: string; role: Role; projects: string[]; workspace_id: string; workspace_name: string; workspace_slug: string; accepted_at: Date | null }>(+ `select i.id, i.email, i.role, i.workspace_id, w.name as workspace_name, w.slug as workspace_slug, i.accepted_at,+ array(select p.name from invitation_projects ip join projects p on p.id = ip.project_id+ where ip.invitation_id = i.id order by p.name) as projects from invitations i join workspaces w on w.id = i.workspace_id where i.token = $1`, [token], );@@ -46,16 +68,22 @@ export function invitationByToken(token: string) { // The invitation is for one address: accepting it signed in as someone else does nothing. export function acceptInvitation(token: string, user: { id: string; email: string }): Promise<string | undefined> { return transaction(async (client) => {- const { rows } = await client.query<{ workspace_id: string; slug: string }>(+ const { rows } = await client.query<{ id: string; workspace_id: string; role: Role; slug: string }>( `update invitations i set accepted_at = now() from workspaces w where i.token = $1 and i.accepted_at is null and lower(i.email) = lower($2) and w.id = i.workspace_id- returning i.workspace_id, w.slug`,+ returning i.id, i.workspace_id, i.role, w.slug`, [token, user.email], ); if (!rows[0]) return undefined;+ await client.query("insert into memberships (workspace_id, user_id, role) values ($1, $2, $3) on conflict do nothing", [+ rows[0].workspace_id,+ user.id,+ rows[0].role,+ ]); await client.query(- "insert into memberships (workspace_id, user_id, role) values ($1, $2, 'member') on conflict do nothing",- [rows[0].workspace_id, user.id],+ `insert into project_guests (workspace_id, user_id, project_id)+ select $2, $3, project_id from invitation_projects where invitation_id = $1 on conflict do nothing`,+ [rows[0].id, rows[0].workspace_id, user.id], ); return rows[0].slug; });@@ -69,7 +97,12 @@ export async function ownerCount(workspaceId: string): Promise<number> { return (await one<{ count: number }>("select count(*)::int as count from memberships where workspace_id = $1 and role = 'owner'", [workspaceId]))!.count; } +export async function addGuestProject(workspaceId: string, userId: string, projectId: string): Promise<void> {+ await query("insert into project_guests (workspace_id, user_id, project_id) values ($1, $2, $3) on conflict do nothing", [workspaceId, userId, projectId]);+}+ // Unassigns the member's open tasks in this workspace so nothing stays assigned to someone who left.+// A guest's project access goes with the membership row. export function removeMember(workspaceId: string, userId: string): Promise<void> { return transaction(async (client) => { await client.query(diff --git a/lib/permissions.ts b/lib/permissions.tsindex dacf296..f0eb98c 100644--- a/lib/permissions.ts+++ b/lib/permissions.ts@@ -1,5 +1,6 @@ // What each workspace role may do. Pages hide what a role can't do; server actions enforce it with authorize().-export type Role = "owner" | "member";+// Guests also see only the projects they were invited to; lib/projects.ts applies that to every project and task query.+export type Role = "owner" | "member" | "guest"; export type Action = | "project.create"@@ -7,12 +8,14 @@ export type Action = | "task.create" | "task.edit" | "task.comment"+ | "member.view" | "member.invite" | "member.remove"; const allowed: Record<Role, ReadonlySet<Action>> = {- owner: new Set<Action>(["project.create", "project.archive", "task.create", "task.edit", "task.comment", "member.invite", "member.remove"]),- member: new Set<Action>(["project.create", "task.create", "task.edit", "task.comment"]),+ owner: new Set<Action>(["project.create", "project.archive", "task.create", "task.edit", "task.comment", "member.view", "member.invite", "member.remove"]),+ member: new Set<Action>(["project.create", "task.create", "task.edit", "task.comment", "member.view"]),+ guest: new Set<Action>(["task.comment"]), }; export function can(role: Role, action: Action): boolean {diff --git a/lib/projects.ts b/lib/projects.tsindex c327122..27f3673 100644--- a/lib/projects.ts+++ b/lib/projects.ts@@ -1,23 +1,32 @@+import type { Access } from "./access"; import { one, query } from "./db"; export type Project = { id: string; name: string; archived_at: Date | null; open_tasks: number }; -export function projectsIn(workspaceId: string): Promise<Project[]> {+// Guests see only the projects they were invited to; owners and members see the whole workspace.+// Every query that reads projects or tasks for someone puts this in its where clause and passes scope(access) as $1 and $2.+export const visibleProject = `p.workspace_id = $1+ and ($2::uuid is null or p.id in (select project_id from project_guests where workspace_id = $1 and user_id = $2))`;++export function scope(access: Access): [string, string | null] {+ return [access.workspace.id, access.role === "guest" ? access.user.id : null];+}++export function projectsIn(access: Access): Promise<Project[]> { return query<Project>( `select p.id, p.name, p.archived_at, (select count(*)::int from tasks t where t.project_id = p.id and t.status <> 'done') as open_tasks- from projects p where p.workspace_id = $1+ from projects p where ${visibleProject} order by p.archived_at is not null, p.created_at`,- [workspaceId],+ scope(access), ); } -// Scoped to the workspace, so an id from another workspace finds nothing.-export function projectIn(workspaceId: string, projectId: string): Promise<Project | undefined> {- return one<Project>(- `select p.id, p.name, p.archived_at, 0 as open_tasks from projects p where p.workspace_id = $1 and p.id = $2`,- [workspaceId, projectId],- );+export function projectIn(access: Access, projectId: string): Promise<Project | undefined> {+ return one<Project>(`select p.id, p.name, p.archived_at, 0 as open_tasks from projects p where ${visibleProject} and p.id = $3`, [+ ...scope(access),+ projectId,+ ]); } export async function createProject(workspaceId: string, name: string): Promise<string> {diff --git a/lib/tasks.ts b/lib/tasks.tsindex e75083b..bf5ee0e 100644--- a/lib/tasks.ts+++ b/lib/tasks.ts@@ -1,4 +1,6 @@+import type { Access } from "./access"; import { one, query, transaction } from "./db";+import { scope, visibleProject } from "./projects"; export const statuses = ["todo", "doing", "done"] as const; export type Status = (typeof statuses)[number];@@ -29,13 +31,13 @@ export function tasksIn(projectId: string): Promise<Task[]> { ); } -export function taskIn(workspaceId: string, taskId: string): Promise<(Task & { project_name: string }) | undefined> {+export function taskIn(access: Access, taskId: string): Promise<(Task & { project_name: string }) | undefined> { return one( `select ${taskColumns}, p.name as project_name from tasks t join projects p on p.id = t.project_id left join users a on a.id = t.assignee_id- where p.workspace_id = $1 and t.id = $2`,- [workspaceId, taskId],+ where ${visibleProject} and t.id = $3`,+ [...scope(access), taskId], ); } diff --git a/migrations/002_guests.sql b/migrations/002_guests.sqlnew file mode 100644index 0000000..3de93dc--- /dev/null+++ b/migrations/002_guests.sql@@ -0,0 +1,22 @@+alter table memberships+ drop constraint memberships_role_check,+ add constraint memberships_role_check check (role in ('owner', 'member', 'guest'));++-- The projects a guest can see. Rows go with the membership, so a removed guest keeps no access.+create table project_guests (+ workspace_id uuid not null,+ user_id uuid not null,+ project_id uuid not null references projects (id) on delete cascade,+ primary key (project_id, user_id),+ foreign key (workspace_id, user_id) references memberships (workspace_id, user_id) on delete cascade+);++alter table invitations add column role text not null default 'member' check (role in ('member', 'guest'));++create table invitation_projects (+ invitation_id uuid not null references invitations (id) on delete cascade,+ project_id uuid not null references projects (id) on delete cascade,+ primary key (invitation_id, project_id)+);++create index on project_guests (workspace_id, user_id);diff --git a/tests/permissions.test.ts b/tests/permissions.test.tsindex fc9f6a5..f254e68 100644--- a/tests/permissions.test.ts+++ b/tests/permissions.test.ts@@ -15,19 +15,29 @@ describe("roles", () => { expect(can("owner", action)).toBe(true); } });++ test("guests comment but don't change the board or see the member list", () => {+ expect(can("guest", "task.comment")).toBe(true);+ for (const action of ["project.create", "project.archive", "task.create", "task.edit", "member.view", "member.invite", "member.remove"] as const) {+ expect(can("guest", action)).toBe(false);+ }+ }); }); describe("removing members", () => { test("an owner can remove members and other owners", () => { expect(canRemoveMember("owner", { role: "member" }, 1)).toBe(true); expect(canRemoveMember("owner", { role: "owner" }, 2)).toBe(true);+ expect(canRemoveMember("owner", { role: "guest" }, 1)).toBe(true); }); test("the last owner can't be removed, not even by themselves", () => { expect(canRemoveMember("owner", { role: "owner" }, 1)).toBe(false); }); - test("members can't remove anyone", () => {+ test("members and guests can't remove anyone", () => { expect(canRemoveMember("member", { role: "member" }, 1)).toBe(false);+ expect(canRemoveMember("member", { role: "guest" }, 1)).toBe(false);+ expect(canRemoveMember("guest", { role: "guest" }, 1)).toBe(false); }); });
diff --git a/app/globals.css b/app/globals.cssindex 80a8cd2..a18543b 100644--- a/app/globals.css+++ b/app/globals.css@@ -280,6 +280,37 @@ td.actions { font-size: 0.8rem; color: var(--muted); }+.add-project {+ display: flex;+ align-items: center;+ gap: 0.5rem;+ margin-top: 0.25rem;+ font-size: 0.875rem;+}+.add-project select {+ padding: 0.2rem 0.4rem;+}+.guest-form {+ max-width: 420px;+}+.guest-form fieldset {+ display: grid;+ gap: 0.25rem;+ margin: 0;+ border: 1px solid var(--line);+ border-radius: 6px;+}+.guest-form legend {+ font-size: 0.9rem;+ color: var(--muted);+}+.guest-form fieldset label {+ display: flex;+ align-items: center;+ gap: 0.5rem;+ color: var(--text);+ font-size: 1rem;+} @media (max-width: 720px) { .board {diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsxindex b5acaa3..5f02fa5 100644--- a/app/invite/[token]/page.tsx+++ b/app/invite/[token]/page.tsx@@ -42,7 +42,11 @@ export default async function Invite(props: PageProps<"/invite/[token]">) { return ( <main className="narrow"> <h1>Join {invitation.workspace_name}</h1>- <p>You'll be able to see and work on every project in the workspace.</p>+ {invitation.role === "guest" ? (+ <p>You'll be able to read and comment on tasks in {invitation.projects.join(", ")}.</p>+ ) : (+ <p>You'll be able to see and work on every project in the workspace.</p>+ )} <form action={accept}> <button type="submit">Accept invitation</button> </form>diff --git a/app/w/[workspace]/actions.ts b/app/w/[workspace]/actions.tsindex 19a41fd..172170c 100644--- a/app/w/[workspace]/actions.ts+++ b/app/w/[workspace]/actions.ts@@ -5,9 +5,9 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { authorize, workspaceAccess } from "@/lib/access"; import { email, text } from "@/lib/forms";-import { invite, membersOf, ownerCount, removeMember, revokeInvitation } from "@/lib/members";-import { canRemoveMember } from "@/lib/permissions";-import { archiveProject, createProject, projectIn } from "@/lib/projects";+import { addGuestProject, invite, membersOf, ownerCount, removeMember, revokeInvitation } from "@/lib/members";+import { can, canRemoveMember } from "@/lib/permissions";+import { archiveProject, createProject, projectIn, projectsIn } from "@/lib/projects"; import { addComment, createTask, moveTask, statuses, taskIn, updateTask, type Status } from "@/lib/tasks"; function status(form: FormData): Status {@@ -33,7 +33,7 @@ export async function archiveProjectAction(form: FormData) { export async function createTaskAction(form: FormData) { const access = await workspaceAccess(text(form, "workspace")); authorize(access, "task.create");- const project = await projectIn(access.workspace.id, text(form, "project"));+ const project = await projectIn(access, text(form, "project")); if (!project || project.archived_at) throw new Error("That project doesn't take new tasks."); await createTask(project.id, access.user.id, text(form, "title", { max: 160 })); revalidatePath(`/w/${access.workspace.slug}/projects/${project.id}`);@@ -42,7 +42,7 @@ export async function createTaskAction(form: FormData) { export async function moveTaskAction(form: FormData) { const access = await workspaceAccess(text(form, "workspace")); authorize(access, "task.edit");- const task = await taskIn(access.workspace.id, text(form, "task"));+ const task = await taskIn(access, text(form, "task")); if (!task) throw new Error("Task not found."); await moveTask(task.id, status(form)); revalidatePath(`/w/${access.workspace.slug}/projects/${task.project_id}`);@@ -51,12 +51,12 @@ export async function moveTaskAction(form: FormData) { export async function updateTaskAction(form: FormData) { const access = await workspaceAccess(text(form, "workspace")); authorize(access, "task.edit");- const task = await taskIn(access.workspace.id, text(form, "task"));+ const task = await taskIn(access, text(form, "task")); if (!task) throw new Error("Task not found."); const assigneeId = text(form, "assignee", { optional: true }) || null;- // Tasks can only be assigned to people in the workspace.- if (assigneeId && !(await membersOf(access.workspace.id)).some((member) => member.user_id === assigneeId)) {- throw new Error("That person isn't in this workspace.");+ // Tasks can only be assigned to people in the workspace who can work on them, so not to guests.+ if (assigneeId && !(await membersOf(access.workspace.id)).some((member) => member.user_id === assigneeId && can(member.role, "task.edit"))) {+ throw new Error("That person can't be assigned tasks in this workspace."); } await updateTask(task.id, { title: text(form, "title", { max: 160 }),@@ -70,7 +70,7 @@ export async function updateTaskAction(form: FormData) { export async function addCommentAction(form: FormData) { const access = await workspaceAccess(text(form, "workspace")); authorize(access, "task.comment");- const task = await taskIn(access.workspace.id, text(form, "task"));+ const task = await taskIn(access, text(form, "task")); if (!task) throw new Error("Task not found."); await addComment(task.id, access.user.id, text(form, "body", { max: 5000 })); revalidatePath(`/w/${access.workspace.slug}/tasks/${task.id}`);@@ -83,7 +83,26 @@ export async function inviteAction(form: FormData) { if ((await membersOf(access.workspace.id)).some((member) => member.email.toLowerCase() === address)) { throw new Error(`${address} is already in this workspace.`); }- await invite(access.workspace.id, address, access.user.id);+ if (form.get("role") === "guest") {+ const projectIds = form.getAll("project").map(String);+ if (projectIds.length === 0) throw new Error("Pick at least one project for the guest.");+ const open = new Set((await projectsIn(access)).filter((project) => !project.archived_at).map((project) => project.id));+ if (!projectIds.every((id) => open.has(id))) throw new Error("Unknown project.");+ await invite(access.workspace.id, address, access.user.id, "guest", projectIds);+ } else {+ await invite(access.workspace.id, address, access.user.id, "member");+ }+ revalidatePath(`/w/${access.workspace.slug}/members`);+}++export async function addGuestProjectAction(form: FormData) {+ const access = await workspaceAccess(text(form, "workspace"));+ authorize(access, "member.invite");+ const guest = (await membersOf(access.workspace.id)).find((member) => member.user_id === text(form, "user"));+ if (guest?.role !== "guest") throw new Error("That person isn't a guest in this workspace.");+ const project = await projectIn(access, text(form, "project"));+ if (!project || project.archived_at) throw new Error("Unknown project.");+ await addGuestProject(access.workspace.id, guest.user_id, project.id); revalidatePath(`/w/${access.workspace.slug}/members`); } diff --git a/app/w/[workspace]/layout.tsx b/app/w/[workspace]/layout.tsxindex 40e5509..a7d2684 100644--- a/app/w/[workspace]/layout.tsx+++ b/app/w/[workspace]/layout.tsx@@ -1,11 +1,12 @@ import Link from "next/link"; import { workspaceAccess } from "@/lib/access";+import { can } from "@/lib/permissions"; import { workspacesOf } from "@/lib/workspaces"; import { signOut } from "../../sign-in/actions"; export default async function WorkspaceLayout(props: LayoutProps<"/w/[workspace]">) { const { workspace: slug } = await props.params;- const { user, workspace } = await workspaceAccess(slug);+ const { user, workspace, role } = await workspaceAccess(slug); const workspaces = await workspacesOf(user.id); return ( <div className="shell">@@ -20,7 +21,7 @@ export default async function WorkspaceLayout(props: LayoutProps<"/w/[workspace] {other.name} </Link> ))}- <Link href={`/w/${workspace.slug}/members`}>Members</Link>+ {can(role, "member.view") && <Link href={`/w/${workspace.slug}/members`}>Members</Link>} </nav> <form action={signOut} className="account"> <span>{user.name}</span>diff --git a/app/w/[workspace]/members/page.tsx b/app/w/[workspace]/members/page.tsxindex 9aa4360..77fcf4d 100644--- a/app/w/[workspace]/members/page.tsx+++ b/app/w/[workspace]/members/page.tsx@@ -1,17 +1,26 @@ import { headers } from "next/headers";+import { notFound } from "next/navigation"; import { workspaceAccess } from "@/lib/access";-import { membersOf, ownerCount, pendingInvitations } from "@/lib/members";-import { can, canRemoveMember } from "@/lib/permissions";-import { inviteAction, removeMemberAction, revokeInvitationAction } from "../actions";+import { membersOf, ownerCount, pendingInvitations, type Member } from "@/lib/members";+import { can, canRemoveMember, type Role } from "@/lib/permissions";+import { projectsIn, type Project } from "@/lib/projects";+import { addGuestProjectAction, inviteAction, removeMemberAction, revokeInvitationAction } from "../actions";++const roleLabels: Record<Role, string> = { owner: "Owner", member: "Member", guest: "Guest" }; export default async function Members(props: PageProps<"/w/[workspace]/members">) { const { workspace: slug } = await props.params; const access = await workspaceAccess(slug);- const [members, invitations, owners] = await Promise.all([+ // Guests don't see who else is in the workspace, or which other projects exist.+ if (!can(access.role, "member.view")) notFound();+ const canInvite = can(access.role, "member.invite");+ const [members, invitations, owners, projects] = await Promise.all([ membersOf(access.workspace.id),- can(access.role, "member.invite") ? pendingInvitations(access.workspace.id) : [],+ canInvite ? pendingInvitations(access.workspace.id) : [], ownerCount(access.workspace.id),+ canInvite ? projectsIn(access) : [], ]);+ const openProjects = projects.filter((project) => !project.archived_at); const host = (await headers()).get("host"); const origin = `${host?.startsWith("localhost") ? "http" : "https"}://${host}`; @@ -27,7 +36,16 @@ export default async function Members(props: PageProps<"/w/[workspace]/members"> <br /> <span className="hint">{member.email}</span> </td>- <td>{member.role === "owner" ? "Owner" : "Member"}</td>+ <td>+ {roleLabels[member.role]}+ {member.role === "guest" && (+ <>+ <br />+ <span className="hint">{member.projects.map((project) => project.name).join(", ")}</span>+ {canInvite && <AddGuestProject slug={slug} guest={member} projects={openProjects} />}+ </>+ )}+ </td> <td className="actions"> {canRemoveMember(access.role, member, owners) && ( <form action={removeMemberAction}>@@ -44,7 +62,7 @@ export default async function Members(props: PageProps<"/w/[workspace]/members"> </tbody> </table> - {can(access.role, "member.invite") && (+ {canInvite && ( <section> <h2>Invite someone</h2> <p className="hint">They'll join as a member and see every project in {access.workspace.name}.</p>@@ -53,31 +71,78 @@ export default async function Members(props: PageProps<"/w/[workspace]/members"> <input type="email" name="email" placeholder="[email protected]" aria-label="Email" required /> <button type="submit">Create invitation</button> </form>- {invitations.length > 0 && (- <>- <h3>Waiting to accept</h3>- <p className="hint">Send each person their link. It works once, for that address.</p>- <ul className="plain">- {invitations.map((invitation) => (- <li key={invitation.id} className="invitation">- <span>- {invitation.email} <span className="hint">invited by {invitation.invited_by_name}</span>- </span>- <input readOnly value={`${origin}/invite/${invitation.token}`} aria-label={`Invitation link for ${invitation.email}`} />- <form action={revokeInvitationAction}>- <input type="hidden" name="workspace" value={slug} />- <input type="hidden" name="invitation" value={invitation.id} />- <button type="submit" className="link danger">- Revoke- </button>- </form>- </li>- ))}- </ul>- </>- )}+ </section>+ )}++ {canInvite && openProjects.length > 0 && (+ <section>+ <h2>Invite a guest</h2>+ <p className="hint">Guests see only the projects you pick. They can read tasks and comment, but can't change the board.</p>+ <form action={inviteAction} className="stack guest-form">+ <input type="hidden" name="workspace" value={slug} />+ <input type="hidden" name="role" value="guest" />+ <input type="email" name="email" placeholder="[email protected]" aria-label="Guest email" required />+ <fieldset>+ <legend>Projects</legend>+ {openProjects.map((project) => (+ <label key={project.id}>+ <input type="checkbox" name="project" value={project.id} /> {project.name}+ </label>+ ))}+ </fieldset>+ <button type="submit">Create guest invitation</button>+ </form>+ </section>+ )}++ {invitations.length > 0 && (+ <section>+ <h2>Waiting to accept</h2>+ <p className="hint">Send each person their link. It works once, for that address.</p>+ <ul className="plain">+ {invitations.map((invitation) => (+ <li key={invitation.id} className="invitation">+ <span>+ {invitation.email}{" "}+ <span className="hint">+ invited by {invitation.invited_by_name}+ {invitation.role === "guest" && ` as a guest on ${invitation.projects.join(", ")}`}+ </span>+ </span>+ <input readOnly value={`${origin}/invite/${invitation.token}`} aria-label={`Invitation link for ${invitation.email}`} />+ <form action={revokeInvitationAction}>+ <input type="hidden" name="workspace" value={slug} />+ <input type="hidden" name="invitation" value={invitation.id} />+ <button type="submit" className="link danger">+ Revoke+ </button>+ </form>+ </li>+ ))}+ </ul> </section> )} </> ); }++function AddGuestProject({ slug, guest, projects }: { slug: string; guest: Member; projects: Project[] }) {+ const addable = projects.filter((project) => !guest.projects.some((on) => on.id === project.id));+ if (addable.length === 0) return null;+ return (+ <form action={addGuestProjectAction} className="add-project">+ <input type="hidden" name="workspace" value={slug} />+ <input type="hidden" name="user" value={guest.user_id} />+ <select name="project" aria-label={`Add ${guest.name} to a project`}>+ {addable.map((project) => (+ <option key={project.id} value={project.id}>+ {project.name}+ </option>+ ))}+ </select>+ <button type="submit" className="link">+ Add+ </button>+ </form>+ );+}diff --git a/app/w/[workspace]/page.tsx b/app/w/[workspace]/page.tsxindex 13488f4..4b99d50 100644--- a/app/w/[workspace]/page.tsx+++ b/app/w/[workspace]/page.tsx@@ -7,7 +7,7 @@ import { createProjectAction } from "./actions"; export default async function Projects(props: PageProps<"/w/[workspace]">) { const { workspace: slug } = await props.params; const access = await workspaceAccess(slug);- const projects = await projectsIn(access.workspace.id);+ const projects = await projectsIn(access); const active = projects.filter((project) => !project.archived_at); const archived = projects.filter((project) => project.archived_at); diff --git a/app/w/[workspace]/projects/[project]/page.tsx b/app/w/[workspace]/projects/[project]/page.tsxindex 7f1658d..9a3c47f 100644--- a/app/w/[workspace]/projects/[project]/page.tsx+++ b/app/w/[workspace]/projects/[project]/page.tsx@@ -9,7 +9,7 @@ import { archiveProjectAction, createTaskAction, moveTaskAction } from "../../ac export default async function Board(props: PageProps<"/w/[workspace]/projects/[project]">) { const { workspace: slug, project: projectId } = await props.params; const access = await workspaceAccess(slug);- const project = await projectIn(access.workspace.id, projectId);+ const project = await projectIn(access, projectId); if (!project) notFound(); const tasks = await tasksIn(project.id); const canEdit = can(access.role, "task.edit") && !project.archived_at;diff --git a/app/w/[workspace]/tasks/[task]/page.tsx b/app/w/[workspace]/tasks/[task]/page.tsxindex 860d13c..3d96770 100644--- a/app/w/[workspace]/tasks/[task]/page.tsx+++ b/app/w/[workspace]/tasks/[task]/page.tsx@@ -9,10 +9,11 @@ import { addCommentAction, updateTaskAction } from "../../actions"; export default async function TaskPage(props: PageProps<"/w/[workspace]/tasks/[task]">) { const { workspace: slug, task: taskId } = await props.params; const access = await workspaceAccess(slug);- const task = await taskIn(access.workspace.id, taskId);+ const task = await taskIn(access, taskId); if (!task) notFound(); const [comments, members] = await Promise.all([commentsOn(task.id), membersOf(access.workspace.id)]); const canEdit = can(access.role, "task.edit");+ const assignees = members.filter((member) => can(member.role, "task.edit")); return ( <>@@ -39,7 +40,7 @@ export default async function TaskPage(props: PageProps<"/w/[workspace]/tasks/[t Assignee <select name="assignee" defaultValue={task.assignee_id ?? ""}> <option value="">Unassigned</option>- {members.map((member) => (+ {assignees.map((member) => ( <option key={member.user_id} value={member.user_id}> {member.name} </option>diff --git a/lib/members.ts b/lib/members.tsindex d86381e..98cbfc2 100644--- a/lib/members.ts+++ b/lib/members.ts@@ -2,42 +2,64 @@ import { randomBytes } from "node:crypto"; import { one, query, transaction } from "./db"; import type { Role } from "./permissions"; -export type Member = { user_id: string; name: string; email: string; role: Role; joined_at: Date };-export type Invitation = { id: string; email: string; token: string; invited_by_name: string; created_at: Date };+// projects lists the projects a guest is on; it's empty for owners and members, who see them all.+export type Member = { user_id: string; name: string; email: string; role: Role; projects: { id: string; name: string }[]; joined_at: Date };+export type Invitation = { id: string; email: string; token: string; role: Role; projects: string[]; invited_by_name: string; created_at: Date }; export function membersOf(workspaceId: string): Promise<Member[]> { return query<Member>(- `select m.user_id, u.name, u.email, m.role, m.created_at as joined_at from memberships m- join users u on u.id = m.user_id where m.workspace_id = $1- order by m.role = 'owner' desc, u.name`,+ `select m.user_id, u.name, u.email, m.role, m.created_at as joined_at,+ coalesce((select json_agg(json_build_object('id', p.id, 'name', p.name) order by p.name)+ from project_guests g join projects p on p.id = g.project_id+ where g.workspace_id = m.workspace_id and g.user_id = m.user_id), '[]') as projects+ from memberships m join users u on u.id = m.user_id where m.workspace_id = $1+ order by m.role = 'owner' desc, m.role = 'guest', u.name`, [workspaceId], ); } export function pendingInvitations(workspaceId: string): Promise<Invitation[]> { return query<Invitation>(- `select i.id, i.email, i.token, u.name as invited_by_name, i.created_at from invitations i- join users u on u.id = i.invited_by where i.workspace_id = $1 and i.accepted_at is null+ `select i.id, i.email, i.token, i.role, u.name as invited_by_name, i.created_at,+ array(select p.name from invitation_projects ip join projects p on p.id = ip.project_id+ where ip.invitation_id = i.id order by p.name) as projects+ from invitations i join users u on u.id = i.invited_by where i.workspace_id = $1 and i.accepted_at is null order by i.created_at desc`, [workspaceId], ); } -// Returns the token for the invitation link. Inviting the same address twice reuses the open invitation.-export async function invite(workspaceId: string, email: string, invitedBy: string): Promise<string> {- const open = await one<{ token: string }>(- "select token from invitations where workspace_id = $1 and lower(email) = lower($2) and accepted_at is null",- [workspaceId, email],- );- if (open) return open.token;- const token = randomBytes(24).toString("base64url");- await query("insert into invitations (workspace_id, email, token, invited_by) values ($1, $2, $3, $4)", [workspaceId, email, token, invitedBy]);- return token;+// Returns the token for the invitation link. Inviting the same address twice reuses the open invitation,+// with the role and projects of the latest one, so a link that's already been sent keeps working.+export function invite(workspaceId: string, email: string, invitedBy: string, role: "member" | "guest", projectIds: string[] = []): Promise<string> {+ return transaction(async (client) => {+ const open = await client.query<{ id: string; token: string }>(+ "select id, token from invitations where workspace_id = $1 and lower(email) = lower($2) and accepted_at is null for update",+ [workspaceId, email],+ );+ let invitation = open.rows[0];+ if (invitation) {+ await client.query("update invitations set role = $2 where id = $1", [invitation.id, role]);+ await client.query("delete from invitation_projects where invitation_id = $1", [invitation.id]);+ } else {+ const token = randomBytes(24).toString("base64url");+ invitation = (+ await client.query<{ id: string; token: string }>(+ "insert into invitations (workspace_id, email, token, invited_by, role) values ($1, $2, $3, $4, $5) returning id, token",+ [workspaceId, email, token, invitedBy, role],+ )+ ).rows[0];+ }+ await client.query("insert into invitation_projects (invitation_id, project_id) select $1, unnest($2::uuid[])", [invitation.id, projectIds]);+ return invitation.token;+ }); } export function invitationByToken(token: string) {- return one<{ id: string; email: string; workspace_id: string; workspace_name: string; workspace_slug: string; accepted_at: Date | null }>(- `select i.id, i.email, i.workspace_id, w.name as workspace_name, w.slug as workspace_slug, i.accepted_at+ return one<{ id: string; email: string; role: Role; projects: string[]; workspace_id: string; workspace_name: string; workspace_slug: string; accepted_at: Date | null }>(+ `select i.id, i.email, i.role, i.workspace_id, w.name as workspace_name, w.slug as workspace_slug, i.accepted_at,+ array(select p.name from invitation_projects ip join projects p on p.id = ip.project_id+ where ip.invitation_id = i.id order by p.name) as projects from invitations i join workspaces w on w.id = i.workspace_id where i.token = $1`, [token], );@@ -46,16 +68,22 @@ export function invitationByToken(token: string) { // The invitation is for one address: accepting it signed in as someone else does nothing. export function acceptInvitation(token: string, user: { id: string; email: string }): Promise<string | undefined> { return transaction(async (client) => {- const { rows } = await client.query<{ workspace_id: string; slug: string }>(+ const { rows } = await client.query<{ id: string; workspace_id: string; role: Role; slug: string }>( `update invitations i set accepted_at = now() from workspaces w where i.token = $1 and i.accepted_at is null and lower(i.email) = lower($2) and w.id = i.workspace_id- returning i.workspace_id, w.slug`,+ returning i.id, i.workspace_id, i.role, w.slug`, [token, user.email], ); if (!rows[0]) return undefined;+ await client.query("insert into memberships (workspace_id, user_id, role) values ($1, $2, $3) on conflict do nothing", [+ rows[0].workspace_id,+ user.id,+ rows[0].role,+ ]); await client.query(- "insert into memberships (workspace_id, user_id, role) values ($1, $2, 'member') on conflict do nothing",- [rows[0].workspace_id, user.id],+ `insert into project_guests (workspace_id, user_id, project_id)+ select $2, $3, project_id from invitation_projects where invitation_id = $1 on conflict do nothing`,+ [rows[0].id, rows[0].workspace_id, user.id], ); return rows[0].slug; });@@ -69,7 +97,12 @@ export async function ownerCount(workspaceId: string): Promise<number> { return (await one<{ count: number }>("select count(*)::int as count from memberships where workspace_id = $1 and role = 'owner'", [workspaceId]))!.count; } +export async function addGuestProject(workspaceId: string, userId: string, projectId: string): Promise<void> {+ await query("insert into project_guests (workspace_id, user_id, project_id) values ($1, $2, $3) on conflict do nothing", [workspaceId, userId, projectId]);+}+ // Unassigns the member's open tasks in this workspace so nothing stays assigned to someone who left.+// A guest's project access goes with the membership row. export function removeMember(workspaceId: string, userId: string): Promise<void> { return transaction(async (client) => { await client.query(diff --git a/lib/permissions.ts b/lib/permissions.tsindex dacf296..f0eb98c 100644--- a/lib/permissions.ts+++ b/lib/permissions.ts@@ -1,5 +1,6 @@ // What each workspace role may do. Pages hide what a role can't do; server actions enforce it with authorize().-export type Role = "owner" | "member";+// Guests also see only the projects they were invited to; lib/projects.ts applies that to every project and task query.+export type Role = "owner" | "member" | "guest"; export type Action = | "project.create"@@ -7,12 +8,14 @@ export type Action = | "task.create" | "task.edit" | "task.comment"+ | "member.view" | "member.invite" | "member.remove"; const allowed: Record<Role, ReadonlySet<Action>> = {- owner: new Set<Action>(["project.create", "project.archive", "task.create", "task.edit", "task.comment", "member.invite", "member.remove"]),- member: new Set<Action>(["project.create", "task.create", "task.edit", "task.comment"]),+ owner: new Set<Action>(["project.create", "project.archive", "task.create", "task.edit", "task.comment", "member.view", "member.invite", "member.remove"]),+ member: new Set<Action>(["project.create", "task.create", "task.edit", "task.comment", "member.view"]),+ guest: new Set<Action>(["task.comment"]), }; export function can(role: Role, action: Action): boolean {diff --git a/lib/projects.ts b/lib/projects.tsindex c327122..27f3673 100644--- a/lib/projects.ts+++ b/lib/projects.ts@@ -1,23 +1,32 @@+import type { Access } from "./access"; import { one, query } from "./db"; export type Project = { id: string; name: string; archived_at: Date | null; open_tasks: number }; -export function projectsIn(workspaceId: string): Promise<Project[]> {+// Guests see only the projects they were invited to; owners and members see the whole workspace.+// Every query that reads projects or tasks for someone puts this in its where clause and passes scope(access) as $1 and $2.+export const visibleProject = `p.workspace_id = $1+ and ($2::uuid is null or p.id in (select project_id from project_guests where workspace_id = $1 and user_id = $2))`;++export function scope(access: Access): [string, string | null] {+ return [access.workspace.id, access.role === "guest" ? access.user.id : null];+}++export function projectsIn(access: Access): Promise<Project[]> { return query<Project>( `select p.id, p.name, p.archived_at, (select count(*)::int from tasks t where t.project_id = p.id and t.status <> 'done') as open_tasks- from projects p where p.workspace_id = $1+ from projects p where ${visibleProject} order by p.archived_at is not null, p.created_at`,- [workspaceId],+ scope(access), ); } -// Scoped to the workspace, so an id from another workspace finds nothing.-export function projectIn(workspaceId: string, projectId: string): Promise<Project | undefined> {- return one<Project>(- `select p.id, p.name, p.archived_at, 0 as open_tasks from projects p where p.workspace_id = $1 and p.id = $2`,- [workspaceId, projectId],- );+export function projectIn(access: Access, projectId: string): Promise<Project | undefined> {+ return one<Project>(`select p.id, p.name, p.archived_at, 0 as open_tasks from projects p where ${visibleProject} and p.id = $3`, [+ ...scope(access),+ projectId,+ ]); } export async function createProject(workspaceId: string, name: string): Promise<string> {diff --git a/lib/tasks.ts b/lib/tasks.tsindex e75083b..bf5ee0e 100644--- a/lib/tasks.ts+++ b/lib/tasks.ts@@ -1,4 +1,6 @@+import type { Access } from "./access"; import { one, query, transaction } from "./db";+import { scope, visibleProject } from "./projects"; export const statuses = ["todo", "doing", "done"] as const; export type Status = (typeof statuses)[number];@@ -29,13 +31,13 @@ export function tasksIn(projectId: string): Promise<Task[]> { ); } -export function taskIn(workspaceId: string, taskId: string): Promise<(Task & { project_name: string }) | undefined> {+export function taskIn(access: Access, taskId: string): Promise<(Task & { project_name: string }) | undefined> { return one( `select ${taskColumns}, p.name as project_name from tasks t join projects p on p.id = t.project_id left join users a on a.id = t.assignee_id- where p.workspace_id = $1 and t.id = $2`,- [workspaceId, taskId],+ where ${visibleProject} and t.id = $3`,+ [...scope(access), taskId], ); } diff --git a/migrations/002_guests.sql b/migrations/002_guests.sqlnew file mode 100644index 0000000..3de93dc--- /dev/null+++ b/migrations/002_guests.sql@@ -0,0 +1,22 @@+alter table memberships+ drop constraint memberships_role_check,+ add constraint memberships_role_check check (role in ('owner', 'member', 'guest'));++-- The projects a guest can see. Rows go with the membership, so a removed guest keeps no access.+create table project_guests (+ workspace_id uuid not null,+ user_id uuid not null,+ project_id uuid not null references projects (id) on delete cascade,+ primary key (project_id, user_id),+ foreign key (workspace_id, user_id) references memberships (workspace_id, user_id) on delete cascade+);++alter table invitations add column role text not null default 'member' check (role in ('member', 'guest'));++create table invitation_projects (+ invitation_id uuid not null references invitations (id) on delete cascade,+ project_id uuid not null references projects (id) on delete cascade,+ primary key (invitation_id, project_id)+);++create index on project_guests (workspace_id, user_id);diff --git a/tests/permissions.test.ts b/tests/permissions.test.tsindex fc9f6a5..f254e68 100644--- a/tests/permissions.test.ts+++ b/tests/permissions.test.ts@@ -15,19 +15,29 @@ describe("roles", () => { expect(can("owner", action)).toBe(true); } });++ test("guests comment but don't change the board or see the member list", () => {+ expect(can("guest", "task.comment")).toBe(true);+ for (const action of ["project.create", "project.archive", "task.create", "task.edit", "member.view", "member.invite", "member.remove"] as const) {+ expect(can("guest", action)).toBe(false);+ }+ }); }); describe("removing members", () => { test("an owner can remove members and other owners", () => { expect(canRemoveMember("owner", { role: "member" }, 1)).toBe(true); expect(canRemoveMember("owner", { role: "owner" }, 2)).toBe(true);+ expect(canRemoveMember("owner", { role: "guest" }, 1)).toBe(true); }); test("the last owner can't be removed, not even by themselves", () => { expect(canRemoveMember("owner", { role: "owner" }, 1)).toBe(false); }); - test("members can't remove anyone", () => {+ test("members and guests can't remove anyone", () => { expect(canRemoveMember("member", { role: "member" }, 1)).toBe(false);+ expect(canRemoveMember("member", { role: "guest" }, 1)).toBe(false);+ expect(canRemoveMember("guest", { role: "guest" }, 1)).toBe(false); }); });
ELI5
TL;DR Owners can now invite someone into just the projects they choose, as a guest, instead of into the whole workspace. Guests see and comment on the work inside those projects and nothing else.
Changed since the last version Owners can now add a project to an existing guest from that guest's row, so a guest's access is no longer fixed when they accept the invitation.
Needs your judgment
What changes
For users
For existing data
For the release
How it works
Before
After
Decisions
What's in the diff
Coverage
app/globals.css: For users (guest invite form and add-project control styling)app/invite/[token]/page.tsx: Decisions 2, 4app/w/[workspace]/actions.ts: Decisions 1, 7, 8, 9 (call sites)app/w/[workspace]/layout.tsx: Decision 6app/w/[workspace]/members/page.tsx: Decisions 5, 7, 9; For usersapp/w/[workspace]/page.tsx: Decision 1 (call site)app/w/[workspace]/projects/[project]/page.tsx: Decision 1 (call site)app/w/[workspace]/tasks/[task]/page.tsx: Decisions 1, 7 (call sites)lib/members.ts: Decisions 2, 3, 4, 5, 9; For existing datalib/permissions.ts: Decision 6lib/projects.ts: Decision 1lib/tasks.ts: Decision 1migrations/002_guests.sql: Decisions 1, 2, 3, 4; For the releasetests/permissions.test.ts: testsNot explained
Explains f833a85 · 👍 / 👎 or reply with what's wrong