feat(sharing): project shares, co-edit safety, awareness UI (Phase 4c+d+e)
Adds project-level sharing via the new project_shares table plus the
infrastructure that makes multi-user editing safe and visible.
Authorization (lib/access.ts):
- getAccessibleProjects / getProjectAccess centralise the predicate
used by every read and write path.
- readableProjectIds / writableProjectIds drive listing-style queries.
- Web UI Server Actions and pages source group memberships from the
user_groups table so authorization works without depending on
Agent A's session callback shape.
Optimistic locking:
- memories + snippets gain version + last_edited_by columns. Every
UPDATE bumps version and stamps the editor; UPDATE WHERE clauses
require the caller's pre-fetched version, surfacing a clear
"refresh and try again" error on lost-write races rather than
silently clobbering.
- MemoryUpdateInput / SnippetPutInput accept an optional version
token.
MCP tools:
- memory.write / .update / .delete / .get / .list / .search,
snippet.put / .get / .list / .delete now respect shared-project
access (read = owner | any share, write = owner | rw share).
- project, defaults to ctx.defaultProjectKey from the X-Project-Key
header (populated by the MCP route — Agent A's wiring).
- project.identify returns shared projects you have access to and
prefers an owned project on key collision, audit-logging the
collision so an operator can debug it.
- Tool descriptions for memory.update, memory.write, snippet.put,
and project.identify updated with the co-edit / shared-project
notes.
Web UI:
- Project detail page: ownership badge, shared-with-N-groups badge,
owner-only "Manage sharing" section (add/flip/remove shares via
lib/share-actions.ts). Add-share is constrained to groups the
granter is already in.
- "Shared" chips on memory cards in /memories and /dashboard.
- "Last edited by ..." on memory + snippet detail pages, shown only
when the last editor isn't the row's original author so the chip
stays informative.
- Read-only viewers (ro shares) lose Edit/Delete affordances on
memories and snippets.
Migration 0004_project_shares.sql adds project_shares + the two new
columns on memories and snippets; it depends on Agent A's
0003_groups.sql for the groups, user_groups, and memory_access enum.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,48 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects } from "@/lib/db/schema";
|
||||
import {
|
||||
groups,
|
||||
memories,
|
||||
projects,
|
||||
projectShares,
|
||||
users,
|
||||
} from "@/lib/db/schema";
|
||||
import {
|
||||
getProjectAccess,
|
||||
getUserGroupNames,
|
||||
readableProjectIds,
|
||||
} from "@/lib/access";
|
||||
import {
|
||||
addProjectShareAction,
|
||||
removeProjectShareAction,
|
||||
updateProjectShareAction,
|
||||
} from "@/lib/share-actions";
|
||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||
import { Card, CardBody } from "@/app/_components/ui/card";
|
||||
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Input, Label } from "@/app/_components/ui/input";
|
||||
import { EmptyState } from "@/app/_components/ui/empty-state";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Project detail page.
|
||||
*
|
||||
* Three personas converge here:
|
||||
* - Owner viewing their own project: full memory list + share-
|
||||
* management UI.
|
||||
* - Member of a group with rw access: same memory list, can edit
|
||||
* memories, but cannot edit shares.
|
||||
* - Member with ro access: memory list rendered read-only-ish; no
|
||||
* "New memory" button.
|
||||
*
|
||||
* Authorization is centralised in `lib/access.ts` so this page only
|
||||
* has to ask "what's my access level" once and branch on the answer.
|
||||
*/
|
||||
export default async function ProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -21,15 +52,66 @@ export default async function ProjectDetailPage({
|
||||
const userId = session!.user.id;
|
||||
const { key: rawKey } = await params;
|
||||
const key = decodeURIComponent(rawKey);
|
||||
const groupNames = await getUserGroupNames(userId);
|
||||
|
||||
const projectRow = await db
|
||||
// Resolve the project. Prefer an owned project; otherwise look for a
|
||||
// shared project with this key the user can read. Mirrors the
|
||||
// MCP-side project.identify priority.
|
||||
const ownedRow = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.userId, userId), eq(projects.key, key)))
|
||||
.limit(1);
|
||||
const project = projectRow[0];
|
||||
if (!project) notFound();
|
||||
|
||||
let project = ownedRow[0];
|
||||
if (!project) {
|
||||
if (groupNames.length === 0) notFound();
|
||||
const readableIds = await readableProjectIds(userId, groupNames);
|
||||
if (readableIds.length === 0) notFound();
|
||||
const sharedRow = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.key, key), inArray(projects.id, readableIds)))
|
||||
.limit(1);
|
||||
if (!sharedRow[0]) notFound();
|
||||
project = sharedRow[0];
|
||||
}
|
||||
|
||||
const access = await getProjectAccess(userId, groupNames, project.id);
|
||||
if (access === null) notFound();
|
||||
const isOwner = access === "owner";
|
||||
const canWrite = access === "owner" || access === "rw";
|
||||
|
||||
// Owner display name for the page header. When the viewer IS the
|
||||
// owner we just say "Owned by you"; otherwise look up the owner.
|
||||
let ownerDisplayName: string | null = null;
|
||||
if (!isOwner) {
|
||||
const ownerRow = await db
|
||||
.select({ name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.id, project.userId))
|
||||
.limit(1);
|
||||
ownerDisplayName = ownerRow[0]?.name ?? ownerRow[0]?.email ?? "another user";
|
||||
}
|
||||
|
||||
// All shares on this project, regardless of viewer's group memberships
|
||||
// — the owner needs to see everything; non-owners see the same list
|
||||
// for situational awareness.
|
||||
const shareRows = await db
|
||||
.select({
|
||||
groupId: groups.id,
|
||||
groupName: groups.name,
|
||||
access: projectShares.access,
|
||||
grantedAt: projectShares.grantedAt,
|
||||
})
|
||||
.from(projectShares)
|
||||
.innerJoin(groups, eq(groups.id, projectShares.groupId))
|
||||
.where(eq(projectShares.projectId, project.id))
|
||||
.orderBy(groups.name);
|
||||
|
||||
// Memories: visible to owner + members alike — anyone with read
|
||||
// access on the project sees every memory under it. The query is
|
||||
// unchanged from the pre-sharing version; project_id is the gate.
|
||||
const mem = await db
|
||||
.select({
|
||||
id: memories.id,
|
||||
@@ -39,53 +121,211 @@ export default async function ProjectDetailPage({
|
||||
createdAt: memories.createdAt,
|
||||
})
|
||||
.from(memories)
|
||||
.where(
|
||||
and(
|
||||
eq(memories.userId, userId),
|
||||
eq(memories.projectId, project.id),
|
||||
isNull(memories.deletedAt),
|
||||
),
|
||||
)
|
||||
.where(and(eq(memories.projectId, project.id), isNull(memories.deletedAt)))
|
||||
.orderBy(desc(memories.createdAt))
|
||||
.limit(100);
|
||||
|
||||
// Groups the viewer is a member of — drives the share-add datalist
|
||||
// for owners (only show groups they could plausibly invite). Returns
|
||||
// an empty list when the user has no group memberships so the
|
||||
// datalist is simply absent rather than emitting a broken IN ().
|
||||
const myGroups =
|
||||
groupNames.length > 0
|
||||
? await db
|
||||
.select({
|
||||
id: groups.id,
|
||||
name: groups.name,
|
||||
displayName: groups.displayName,
|
||||
})
|
||||
.from(groups)
|
||||
.where(inArray(groups.name, groupNames))
|
||||
.limit(50)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Container className="pt-6">
|
||||
<PageHeader
|
||||
title={project.displayName ?? project.key}
|
||||
description={
|
||||
<>
|
||||
<span className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono">{project.key}</span>
|
||||
{" · "}
|
||||
{mem.length} memor{mem.length === 1 ? "y" : "ies"}
|
||||
</>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{mem.length} memor{mem.length === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
<span>·</span>
|
||||
{isOwner ? (
|
||||
<Badge tone="success">Owned by you</Badge>
|
||||
) : (
|
||||
<span className="text-fg-subtle">Owned by {ownerDisplayName}</span>
|
||||
)}
|
||||
{shareRows.length > 0 ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge tone="accent">
|
||||
Shared with {shareRows.length} group
|
||||
{shareRows.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</>
|
||||
) : null}
|
||||
{!isOwner ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge tone={access === "rw" ? "success" : "neutral"}>
|
||||
{access === "rw" ? "read + write" : "read only"}
|
||||
</Badge>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Link href="/projects" className="no-underline">
|
||||
<Button type="button" variant="secondary">All projects</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button>New in this project</Button>
|
||||
</Link>
|
||||
{canWrite ? (
|
||||
<Link
|
||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button>New in this project</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{shareRows.length > 0 || isOwner ? (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Sharing</span>
|
||||
<span className="text-xs text-fg-subtle">
|
||||
{shareRows.length === 0
|
||||
? "No groups have access"
|
||||
: `${shareRows.length} group${shareRows.length === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-3">
|
||||
{shareRows.length === 0 && !isOwner ? (
|
||||
<p className="text-sm text-fg-subtle">Only the owner has access.</p>
|
||||
) : null}
|
||||
|
||||
{shareRows.length > 0 ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{shareRows.map((s) => (
|
||||
<li
|
||||
key={s.groupId}
|
||||
className="flex items-center gap-3 py-2 text-sm"
|
||||
>
|
||||
<Badge tone="accent">{s.groupName}</Badge>
|
||||
<Badge tone={s.access === "rw" ? "success" : "neutral"}>
|
||||
{s.access}
|
||||
</Badge>
|
||||
<span className="text-xs text-fg-subtle">
|
||||
since {new Date(s.grantedAt).toLocaleDateString()}
|
||||
</span>
|
||||
{isOwner ? (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<form action={updateProjectShareAction}>
|
||||
<input type="hidden" name="projectKey" value={project.key} />
|
||||
<input type="hidden" name="groupId" value={s.groupId} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="access"
|
||||
value={s.access === "rw" ? "ro" : "rw"}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title={
|
||||
s.access === "rw"
|
||||
? "Downgrade to read-only"
|
||||
: "Promote to read-write"
|
||||
}
|
||||
>
|
||||
{s.access === "rw" ? "→ ro" : "→ rw"}
|
||||
</Button>
|
||||
</form>
|
||||
<form action={removeProjectShareAction}>
|
||||
<input type="hidden" name="projectKey" value={project.key} />
|
||||
<input type="hidden" name="groupId" value={s.groupId} />
|
||||
<Button type="submit" variant="danger" size="sm">
|
||||
Remove
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{isOwner ? (
|
||||
<form
|
||||
action={addProjectShareAction}
|
||||
className="flex flex-wrap items-end gap-2 pt-2 border-t border-border"
|
||||
>
|
||||
<input type="hidden" name="projectKey" value={project.key} />
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Label htmlFor="groupName" hint="must be a group you're a member of">
|
||||
Group name
|
||||
</Label>
|
||||
<Input
|
||||
id="groupName"
|
||||
name="groupName"
|
||||
list="my-group-list"
|
||||
placeholder="engineering"
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
{myGroups.length > 0 ? (
|
||||
<datalist id="my-group-list">
|
||||
{myGroups.map((g) => (
|
||||
<option key={g.id} value={g.name}>
|
||||
{g.displayName ?? g.name}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Access</Label>
|
||||
<div className="mt-1 flex items-center gap-3 h-9">
|
||||
<label className="text-sm flex items-center gap-1">
|
||||
<input type="radio" name="access" value="ro" defaultChecked />
|
||||
ro
|
||||
</label>
|
||||
<label className="text-sm flex items-center gap-1">
|
||||
<input type="radio" name="access" value="rw" />
|
||||
rw
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit">Add share</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{mem.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No memories in this project yet"
|
||||
description="Use the MCP from a Claude Code session, or create one here."
|
||||
description={
|
||||
canWrite
|
||||
? "Use the MCP from a Claude Code session, or create one here."
|
||||
: "Members with write access can add memories from the MCP or the Web UI."
|
||||
}
|
||||
action={
|
||||
<Link
|
||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button>Create the first one</Button>
|
||||
</Link>
|
||||
canWrite ? (
|
||||
<Link
|
||||
href={`/memories/new?project=${encodeURIComponent(project.key)}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button>Create the first one</Button>
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -99,6 +339,14 @@ export default async function ProjectDetailPage({
|
||||
<Badge tone={m.scope === "user" ? "accent" : "neutral"}>
|
||||
{m.scope}
|
||||
</Badge>
|
||||
{shareRows.length > 0 ? (
|
||||
<Badge
|
||||
tone="accent"
|
||||
title={`Shared with ${shareRows.map((s) => s.groupName).join(", ")}`}
|
||||
>
|
||||
Shared
|
||||
</Badge>
|
||||
) : null}
|
||||
<span>{new Date(m.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="text-sm text-fg line-clamp-3">{m.content}</p>
|
||||
|
||||
Reference in New Issue
Block a user