From e71febe51afb5edd1bc9e9587f85a8cadaacdcff Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Tue, 5 May 2026 12:25:16 -0700 Subject: [PATCH] fix(sharing): harden note share revocation flows --- .../modules/note-collaborators/routes.test.ts | 34 +++++- .../src/modules/note-collaborators/routes.ts | 33 +++++- backend/src/modules/note-shares/repository.ts | 4 + backend/src/modules/note-shares/types.ts | 1 + .../modules/notes/routes.integration.test.ts | 68 ++++++++++++ backend/src/modules/notes/routes.test.ts | 10 +- backend/src/modules/notes/routes.ts | 68 +++++++++++- backend/src/server.ts | 3 + web/src/app/share/[token]/page.tsx | 5 +- web/src/components/ShareDialog.test.tsx | 91 ++++++++++++++++ web/src/components/ShareDialog.tsx | 102 ++++++++++++++++-- web/src/lib/intake-client.ts | 3 +- web/src/lib/notes-client.ts | 26 ++++- 13 files changed, 426 insertions(+), 22 deletions(-) create mode 100644 web/src/components/ShareDialog.test.tsx diff --git a/backend/src/modules/note-collaborators/routes.test.ts b/backend/src/modules/note-collaborators/routes.test.ts index 4c1d8da..aca7ef1 100644 --- a/backend/src/modules/note-collaborators/routes.test.ts +++ b/backend/src/modules/note-collaborators/routes.test.ts @@ -91,15 +91,26 @@ describe('note-collaborators routes', () => { describe('GET /notes/:id/collaborators', () => { it('lists collaborators', async () => { + getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'user_1', productId: 'notelett' }); listCollaboratorsForNoteMock.mockResolvedValueOnce([ { id: 'c1', sharedWithUserId: 'user_2', permission: 'view' }, ]); const app = await buildApp(); - const res = await app.inject({ method: 'GET', url: '/api/notes/n1/collaborators' }); + const res = await app.inject({ method: 'GET', url: '/api/notes/n1/collaborators?workspaceId=ws-1' }); expect(res.statusCode).toBe(200); expect(res.json().items).toHaveLength(1); }); + + it('requires owner access to list collaborators', async () => { + getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'owner_1', productId: 'notelett' }); + findCollaboratorMock.mockResolvedValueOnce({ id: 'c1', workspaceId: 'ws-1', permission: 'view' }); + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/notes/n1/collaborators?workspaceId=ws-1' }); + + expect(res.statusCode).toBe(404); + expect(listCollaboratorsForNoteMock).not.toHaveBeenCalled(); + }); }); describe('GET /shared-with-me', () => { @@ -144,7 +155,7 @@ describe('note-collaborators routes', () => { describe('POST /notes/:id/export-text', () => { it('exports note as text formats', async () => { - getNoteMock.mockResolvedValueOnce({ + getNoteMock.mockResolvedValue({ id: 'n1', userId: 'user_1', productId: 'notelett', title: 'Test Note', body: '

Hello world

', }); @@ -163,6 +174,23 @@ describe('note-collaborators routes', () => { expect(body.html).toBe('

Hello world

'); }); + it('allows a direct collaborator to export note text', async () => { + getNoteMock.mockResolvedValue({ + id: 'n1', userId: 'owner_1', productId: 'notelett', + title: 'Shared Note', body: '

Hello shared

', + }); + findCollaboratorMock.mockResolvedValueOnce({ id: 'c1', workspaceId: 'ws-1', permission: 'view' }); + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/n1/export-text', + payload: { workspaceId: 'ws-1' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().title).toBe('Shared Note'); + }); + it('returns 404 for missing note', async () => { getNoteMock.mockResolvedValueOnce(null); const app = await buildApp(); @@ -178,7 +206,7 @@ describe('note-collaborators routes', () => { describe('GET /notes/:id/deep-link', () => { it('returns deep link URLs', async () => { - getNoteMock.mockResolvedValueOnce({ id: 'n1', productId: 'notelett' }); + getNoteMock.mockResolvedValue({ id: 'n1', userId: 'user_1', productId: 'notelett' }); const app = await buildApp(); const res = await app.inject({ method: 'GET', diff --git a/backend/src/modules/note-collaborators/routes.ts b/backend/src/modules/note-collaborators/routes.ts index 4c40a9d..42f7b0b 100644 --- a/backend/src/modules/note-collaborators/routes.ts +++ b/backend/src/modules/note-collaborators/routes.ts @@ -14,7 +14,20 @@ import * as noteRepo from '../notes/repository.js'; import * as collabRepo from './repository.js'; import { ShareWithUserSchema, ExportTextSchema } from './types.js'; import type { NoteCollaboratorDoc } from './types.js'; -import { config } from '../../lib/config.js'; +async function getNoteAccess(noteId: string, workspaceId: string, userId: string): Promise<'owner' | 'view' | 'comment' | 'edit' | null> { + const note = await noteRepo.getNote(noteId, workspaceId); + if (!note || note.productId !== PRODUCT_ID) { + return null; + } + if (note.userId === userId) { + return 'owner'; + } + const collaborator = await collabRepo.findCollaborator(noteId, userId, PRODUCT_ID); + if (!collaborator || collaborator.workspaceId !== workspaceId) { + return null; + } + return collaborator.permission; +} export async function noteCollaboratorRoutes(app: FastifyInstance): Promise { @@ -73,6 +86,14 @@ export async function noteCollaboratorRoutes(app: FastifyInstance): Promise { + await collection().delete(id, workspaceId); +} diff --git a/backend/src/modules/note-shares/types.ts b/backend/src/modules/note-shares/types.ts index 0aae7d0..8973859 100644 --- a/backend/src/modules/note-shares/types.ts +++ b/backend/src/modules/note-shares/types.ts @@ -15,4 +15,5 @@ export interface NoteShareDoc { export const CreateNoteShareSchema = z.object({ workspaceId: z.string().min(1).max(128), + expiresInDays: z.coerce.number().int().min(1).max(365).default(30), }); diff --git a/backend/src/modules/notes/routes.integration.test.ts b/backend/src/modules/notes/routes.integration.test.ts index f2319e0..3a2752a 100644 --- a/backend/src/modules/notes/routes.integration.test.ts +++ b/backend/src/modules/notes/routes.integration.test.ts @@ -14,6 +14,7 @@ vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() })); vi.mock('../../lib/feature-flags.js', () => ({ isFeatureEnabled: vi.fn(() => true) })); import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; +import { createCollaborator } from '../note-collaborators/repository.js'; import { noteRoutes } from './routes.js'; let app: FastifyInstance; @@ -68,6 +69,26 @@ describe('notes routes — integration', () => { expect(res.json().title).toBe('Test Note'); }); + it('GET /notes/:id allows direct collaborators to read the note', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + await createCollaborator({ + id: 'collab-1', + productId: 'notelett', + noteId: 'note-1', + workspaceId: 'ws-1', + sharedByUserId: 'user_1', + sharedWithUserId: 'user_2', + permission: 'view', + createdAt: '2026-05-05T00:00:00.000Z', + }); + extractAuthMock.mockResolvedValueOnce({ sub: 'user_2', type: 'access', role: 'viewer' }); + + const res = await app.inject({ method: 'GET', url: '/api/notes/note-1?workspaceId=ws-1' }); + + expect(res.statusCode).toBe(200); + expect(res.json().title).toBe('Test Note'); + }); + it('GET /notes/:id returns 404 for missing note', async () => { const res = await app.inject({ method: 'GET', url: '/api/notes/missing?workspaceId=ws-1' }); expect(res.statusCode).toBe(404); @@ -89,6 +110,29 @@ describe('notes routes — integration', () => { expect(res.json().title).toBe('Updated'); }); + it('PATCH /notes/:id requires owner or edit collaborator access', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + await createCollaborator({ + id: 'collab-view', + productId: 'notelett', + noteId: 'note-1', + workspaceId: 'ws-1', + sharedByUserId: 'user_1', + sharedWithUserId: 'user_2', + permission: 'view', + createdAt: '2026-05-05T00:00:00.000Z', + }); + extractAuthMock.mockResolvedValueOnce({ sub: 'user_2', type: 'access', role: 'editor' }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/notes/note-1?workspaceId=ws-1', + payload: { title: 'Unauthorized' }, + }); + + expect(res.statusCode).toBe(404); + }); + it('POST /notes/:id/archive sets status to archived', async () => { await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); const res = await app.inject({ @@ -212,6 +256,30 @@ describe('notes routes — integration', () => { expect(res.statusCode).toBe(400); }); + it('creates, lists, and revokes expiring public note shares', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + const createRes = await app.inject({ + method: 'POST', + url: '/api/notes/note-1/share', + payload: { workspaceId: 'ws-1', expiresInDays: 7 }, + }); + expect(createRes.statusCode).toBe(201); + expect(createRes.json().expiresAt).toBeDefined(); + + const listRes = await app.inject({ method: 'GET', url: '/api/notes/note-1/shares?workspaceId=ws-1' }); + expect(listRes.statusCode).toBe(200); + expect(listRes.json().items).toHaveLength(1); + + const deleteRes = await app.inject({ + method: 'DELETE', + url: `/api/notes/note-1/shares/${createRes.json().shareToken}?workspaceId=ws-1`, + }); + expect(deleteRes.statusCode).toBe(204); + + const afterDelete = await app.inject({ method: 'GET', url: '/api/notes/note-1/shares?workspaceId=ws-1' }); + expect(afterDelete.json().items).toHaveLength(0); + }); + it('returns 401 when auth fails', async () => { extractAuthMock.mockRejectedValueOnce(new Error('Unauthorized')); const res = await app.inject({ method: 'GET', url: '/api/notes' }); diff --git a/backend/src/modules/notes/routes.test.ts b/backend/src/modules/notes/routes.test.ts index 9690ce4..6ca4d84 100644 --- a/backend/src/modules/notes/routes.test.ts +++ b/backend/src/modules/notes/routes.test.ts @@ -39,6 +39,12 @@ vi.mock('../note-versions/repository.js', () => ({ })); vi.mock('../note-shares/repository.js', () => ({ createNoteShare: vi.fn(async () => ({})), + findShareByToken: vi.fn(async () => null), + listSharesForNote: vi.fn(async () => []), + deleteShare: vi.fn(async () => {}), +})); +vi.mock('../note-collaborators/repository.js', () => ({ + findCollaborator: vi.fn(async () => null), })); describe('noteRoutes', () => { @@ -56,9 +62,9 @@ describe('noteRoutes', () => { await noteRoutes(app as never); - expect(app.get).toHaveBeenCalledTimes(5); + expect(app.get).toHaveBeenCalledTimes(6); expect(app.post).toHaveBeenCalledTimes(9); expect(app.patch).toHaveBeenCalledTimes(1); - expect(app.delete).toHaveBeenCalledTimes(1); + expect(app.delete).toHaveBeenCalledTimes(2); }); }); diff --git a/backend/src/modules/notes/routes.ts b/backend/src/modules/notes/routes.ts index b65f3a2..03e2008 100644 --- a/backend/src/modules/notes/routes.ts +++ b/backend/src/modules/notes/routes.ts @@ -14,6 +14,7 @@ import { getRequestId } from '../../lib/request-context.js'; import * as repo from './repository.js'; import * as artifactRepo from '../note-artifacts/repository.js'; import * as shareRepo from '../note-shares/repository.js'; +import * as collaboratorRepo from '../note-collaborators/repository.js'; import * as versionRepo from '../note-versions/repository.js'; import { CreateNoteShareSchema } from '../note-shares/types.js'; import { ListNoteVersionsQuerySchema } from '../note-versions/types.js'; @@ -68,6 +69,17 @@ function toLexicalHits(items: NoteDoc[]) { }); } +async function getNoteAccess(note: NoteDoc, userId: string): Promise<'owner' | 'view' | 'comment' | 'edit' | null> { + if (note.userId === userId) return 'owner'; + const collaborator = await collaboratorRepo.findCollaborator(note.id, userId, PRODUCT_ID); + if (!collaborator || collaborator.workspaceId !== note.workspaceId) return null; + return collaborator.permission; +} + +function canEditNote(access: Awaited>): boolean { + return access === 'owner' || access === 'edit'; +} + async function listAllNotesForExport( userId: string, workspaceId?: string, @@ -157,7 +169,11 @@ export async function noteRoutes(app: RouteApp) { } const note = await repo.getNote(id, workspaceId); - if (!note || note.userId !== auth.sub || note.productId !== PRODUCT_ID) { + if (!note || note.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + const access = await getNoteAccess(note, auth.sub); + if (!access) { throw new NotFoundError('Note not found'); } @@ -303,7 +319,11 @@ export async function noteRoutes(app: RouteApp) { } const existing = await repo.getNote(id, workspaceId); - if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { + if (!existing || existing.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + const access = await getNoteAccess(existing, auth.sub); + if (!canEditNote(access)) { throw new NotFoundError('Note not found'); } @@ -449,6 +469,7 @@ export async function noteRoutes(app: RouteApp) { const shareToken = randomUUID(); const now = new Date().toISOString(); + const expiresAt = new Date(Date.now() + parsed.data.expiresInDays * 24 * 60 * 60 * 1000).toISOString(); await shareRepo.createNoteShare({ id: `sh-${shareToken}`, productId: PRODUCT_ID, @@ -457,10 +478,51 @@ export async function noteRoutes(app: RouteApp) { noteId: id, shareToken, createdAt: now, + expiresAt, }); trackEvent('note.share_created', auth.sub, { noteId: id, workspaceId }); reply.code(201); - return { shareToken, path: `/share/${shareToken}` }; + return { shareToken, path: `/share/${shareToken}`, expiresAt }; + }); + + app.get('/notes/:id/shares', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const workspaceId = (req.query as { workspaceId?: string }).workspaceId; + if (!workspaceId) { + throw new BadRequestError('workspaceId is required'); + } + + const existing = await repo.getNote(id, workspaceId); + if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + + const shares = await shareRepo.listSharesForNote(auth.sub, PRODUCT_ID, workspaceId, id); + return { items: shares, total: shares.length }; + }); + + app.delete('/notes/:id/shares/:shareToken', async (req, reply) => { + const auth = await requireWriter(req); + const { id, shareToken } = req.params as { id: string; shareToken: string }; + const workspaceId = (req.query as { workspaceId?: string }).workspaceId; + if (!workspaceId) { + throw new BadRequestError('workspaceId is required'); + } + + const existing = await repo.getNote(id, workspaceId); + if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + + const share = await shareRepo.findShareByToken(shareToken, PRODUCT_ID); + if (!share || share.noteId !== id || share.workspaceId !== workspaceId || share.userId !== auth.sub) { + throw new NotFoundError('Share not found'); + } + + await shareRepo.deleteShare(share.id, workspaceId); + trackEvent('note.share_revoked', auth.sub, { noteId: id, workspaceId }); + reply.code(204).send(); }); app.post('/notes/:id/copilot', async req => { diff --git a/backend/src/server.ts b/backend/src/server.ts index f3a5959..1a6e744 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -100,6 +100,8 @@ app.get('/api/public/note-shares/:token', async (req, reply) => { reply.code(404); return { error: 'Note not available' }; } + reply.header('Cache-Control', 'no-store'); + reply.header('X-Robots-Tag', 'noindex, nofollow'); return { product: DISPLAY_NAME, noteId: note.id, @@ -107,6 +109,7 @@ app.get('/api/public/note-shares/:token', async (req, reply) => { title: note.title, body: note.body, updatedAt: note.updatedAt, + expiresAt: share.expiresAt ?? null, }; }); diff --git a/web/src/app/share/[token]/page.tsx b/web/src/app/share/[token]/page.tsx index 0c4fb3e..29dc3fb 100644 --- a/web/src/app/share/[token]/page.tsx +++ b/web/src/app/share/[token]/page.tsx @@ -10,6 +10,7 @@ type PublicNote = { body: string; noteId: string; updatedAt: string; + expiresAt?: string | null; }; export default function SharedNotePage() { @@ -48,7 +49,7 @@ export default function SharedNotePage() {
- Read-only share · {PRODUCT_NAME} + Read-only public share · {PRODUCT_NAME}

{note?.title ?? (error ? "Unavailable" : "Loading…")}

@@ -57,7 +58,7 @@ export default function SharedNotePage() { {note ? ( <>

- Updated {new Date(note.updatedAt).toLocaleString()} · Note ID {note.noteId} + Updated {new Date(note.updatedAt).toLocaleString()} · {note.expiresAt ? `Expires ${new Date(note.expiresAt).toLocaleDateString()}` : "Expires when revoked"} · Note ID {note.noteId}

({ + createNoteShareMock: vi.fn(async () => ({ shareToken: "tok-new", path: "/share/tok-new", expiresAt: "2026-06-05T00:00:00.000Z" })), + listNoteSharesMock: vi.fn(async () => ({ + items: [{ id: "share-1", noteId: "note-1", workspaceId: "ws-1", shareToken: "tok-old", createdAt: "2026-05-05T00:00:00.000Z", expiresAt: "2026-06-04T00:00:00.000Z" }], + total: 1, + })), + revokeNoteShareMock: vi.fn(async () => {}), + exportNoteTextMock: vi.fn(async () => ({ title: "Note", plaintext: "Body", markdown: "Body", html: "

Body

" })), + listCollaboratorsMock: vi.fn(async () => ({ + items: [{ id: "collab-1", sharedWithUserId: "user-2", permission: "view", createdAt: "2026-05-05T00:00:00.000Z" }], + total: 1, + })), + removeCollaboratorMock: vi.fn(async () => {}), + shareNoteWithUserMock: vi.fn(async () => ({})), + toastMock: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/lib/notes-client", () => ({ + createNoteShare: createNoteShareMock, + listNoteShares: listNoteSharesMock, + revokeNoteShare: revokeNoteShareMock, +})); + +vi.mock("@/lib/intake-client", () => ({ + exportNoteText: exportNoteTextMock, + listCollaborators: listCollaboratorsMock, + removeCollaborator: removeCollaboratorMock, + shareNoteWithUser: shareNoteWithUserMock, +})); + +vi.mock("@/lib/product-config", () => ({ + getWebAppOrigin: () => "https://notelett.app", +})); + +vi.mock("@/lib/toast", () => ({ + toast: toastMock, +})); + +import { ShareDialog } from "@/components/ShareDialog"; + +describe("ShareDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(navigator, { + clipboard: { writeText: vi.fn(async () => {}) }, + }); + }); + + it("shows expiring public links and revokes them", async () => { + render(); + + expect(await screen.findByText(/expires/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Revoke" })); + + await waitFor(() => expect(revokeNoteShareMock).toHaveBeenCalledWith("note-1", "ws-1", "tok-old")); + expect(toastMock.success).toHaveBeenCalledWith("Public link revoked"); + }); + + it("copies a new public link with expiration copy", async () => { + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Copy share link" })); + + await waitFor(() => expect(createNoteShareMock).toHaveBeenCalledWith("note-1", "ws-1")); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("https://notelett.app/share/tok-new"); + expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining("Share link copied")); + }); + + it("shows direct collaborators and removes access", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Share with User" })); + expect(await screen.findByText("user-2 · view")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + + await waitFor(() => expect(removeCollaboratorMock).toHaveBeenCalledWith("note-1", "user-2")); + expect(toastMock.success).toHaveBeenCalledWith("Access removed for user-2"); + }); +}); diff --git a/web/src/components/ShareDialog.tsx b/web/src/components/ShareDialog.tsx index 6f0546d..9a84cb8 100644 --- a/web/src/components/ShareDialog.tsx +++ b/web/src/components/ShareDialog.tsx @@ -1,10 +1,10 @@ "use client"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "@/lib/toast"; import { Button, Card } from "@/components/ui/Primitives"; -import { createNoteShare } from "@/lib/notes-client"; -import { exportNoteText, shareNoteWithUser } from "@/lib/intake-client"; +import { createNoteShare, listNoteShares, revokeNoteShare, type PublicNoteShare } from "@/lib/notes-client"; +import { exportNoteText, listCollaborators, removeCollaborator, shareNoteWithUser } from "@/lib/intake-client"; import { getWebAppOrigin } from "@/lib/product-config"; interface ShareDialogProps { @@ -14,19 +14,42 @@ interface ShareDialogProps { onClose: () => void; } +type CollaboratorRow = { + id?: string; + sharedWithUserId?: string; + permission?: "view" | "comment" | "edit"; + createdAt?: string; +}; + export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDialogProps) { const [tab, setTab] = useState<"link" | "user" | "text" | "native">("link"); const [userId, setUserId] = useState(""); const [permission, setPermission] = useState<"view" | "comment" | "edit">("view"); const [loading, setLoading] = useState(false); + const [publicLinks, setPublicLinks] = useState([]); + const [collaborators, setCollaborators] = useState([]); + + const reloadSharingState = useCallback(async () => { + const [links, collabs] = await Promise.all([ + listNoteShares(noteId, workspaceId).catch(() => ({ items: [] as PublicNoteShare[], total: 0 })), + listCollaborators(noteId, workspaceId).catch(() => ({ items: [] as unknown[], total: 0 })), + ]); + setPublicLinks(links.items); + setCollaborators(collabs.items as CollaboratorRow[]); + }, [noteId, workspaceId]); + + useEffect(() => { + void reloadSharingState(); + }, [reloadSharingState]); async function handleCopyLink() { setLoading(true); try { - const { shareToken } = await createNoteShare(noteId, workspaceId); + const { shareToken, expiresAt } = await createNoteShare(noteId, workspaceId); const url = `${getWebAppOrigin()}/share/${shareToken}`; await navigator.clipboard.writeText(url); - toast.success("Share link copied"); + await reloadSharingState(); + toast.success(expiresAt ? `Share link copied; expires ${new Date(expiresAt).toLocaleDateString()}` : "Share link copied"); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to create share link"); } finally { @@ -44,6 +67,7 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi await shareNoteWithUser(noteId, workspaceId, userId.trim(), permission); toast.success(`Shared with ${userId.trim()}`); setUserId(""); + await reloadSharingState(); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to share"); } finally { @@ -51,6 +75,32 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi } } + async function handleRevokeLink(shareToken: string) { + setLoading(true); + try { + await revokeNoteShare(noteId, workspaceId, shareToken); + await reloadSharingState(); + toast.success("Public link revoked"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to revoke link"); + } finally { + setLoading(false); + } + } + + async function handleRemoveCollaborator(targetUserId: string) { + setLoading(true); + try { + await removeCollaborator(noteId, targetUserId); + await reloadSharingState(); + toast.success(`Access removed for ${targetUserId}`); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to remove access"); + } finally { + setLoading(false); + } + } + async function handleCopyText() { setLoading(true); try { @@ -150,11 +200,29 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi {tab === "link" && (

- Generate a public read-only link anyone can view. + Generate a public read-only link that expires in 30 days. Revoke links you no longer want available.

+ {publicLinks.length === 0 ? ( +

+ No active public links. +

+ ) : ( +
+ {publicLinks.map((link) => ( +
+ + Expires {link.expiresAt ? new Date(link.expiresAt).toLocaleDateString() : "when revoked"} + + +
+ ))} +
+ )}
)} @@ -196,6 +264,26 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi + {collaborators.length === 0 ? ( +

+ No direct collaborators yet. +

+ ) : ( +
+ {collaborators.map((collaborator) => ( +
+ + {collaborator.sharedWithUserId} · {collaborator.permission ?? "view"} + + {collaborator.sharedWithUserId ? ( + + ) : null} +
+ ))} +
+ )}
)} diff --git a/web/src/lib/intake-client.ts b/web/src/lib/intake-client.ts index 0cc4eaf..4dbb3f4 100644 --- a/web/src/lib/intake-client.ts +++ b/web/src/lib/intake-client.ts @@ -143,9 +143,10 @@ export async function shareNoteWithUser( export async function listCollaborators( noteId: string, + workspaceId: string, ): Promise<{ items: unknown[]; total: number }> { const api = createNotesApiClient(); - return api.fetch(`/notes/${encodeURIComponent(noteId)}/collaborators`); + return api.fetch(`/notes/${encodeURIComponent(noteId)}/collaborators?workspaceId=${encodeURIComponent(workspaceId)}`); } export async function listSharedWithMe(): Promise<{ diff --git a/web/src/lib/notes-client.ts b/web/src/lib/notes-client.ts index 2d31ee3..9143ad9 100644 --- a/web/src/lib/notes-client.ts +++ b/web/src/lib/notes-client.ts @@ -204,14 +204,36 @@ export async function searchNotesRanked( }); } -export async function createNoteShare(noteId: string, workspaceId: string): Promise<{ shareToken: string; path: string }> { +export interface PublicNoteShare { + id: string; + noteId: string; + workspaceId: string; + shareToken: string; + createdAt: string; + expiresAt?: string; +} + +export async function createNoteShare(noteId: string, workspaceId: string): Promise<{ shareToken: string; path: string; expiresAt?: string }> { const api = createNotesApiClient(); return api.fetch(`/notes/${encodeURIComponent(noteId)}/share`, { method: "POST", - body: JSON.stringify({ workspaceId }), + body: JSON.stringify({ workspaceId, expiresInDays: 30 }), }); } +export async function listNoteShares(noteId: string, workspaceId: string): Promise<{ items: PublicNoteShare[]; total: number }> { + const api = createNotesApiClient(); + return api.fetch(`/notes/${encodeURIComponent(noteId)}/shares?workspaceId=${encodeURIComponent(workspaceId)}`); +} + +export async function revokeNoteShare(noteId: string, workspaceId: string, shareToken: string): Promise { + const api = createNotesApiClient(); + await api.fetch( + `/notes/${encodeURIComponent(noteId)}/shares/${encodeURIComponent(shareToken)}?workspaceId=${encodeURIComponent(workspaceId)}`, + { method: "DELETE" }, + ); +} + export interface NoteVersionRow { id: string; noteId: string;