fix(sharing): harden note share revocation flows

This commit is contained in:
Saravana Achu Mac 2026-05-05 12:25:16 -07:00
parent 5101c847da
commit e71febe51a
13 changed files with 426 additions and 22 deletions

View File

@ -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: '<p>Hello world</p>',
});
@ -163,6 +174,23 @@ describe('note-collaborators routes', () => {
expect(body.html).toBe('<p>Hello world</p>');
});
it('allows a direct collaborator to export note text', async () => {
getNoteMock.mockResolvedValue({
id: 'n1', userId: 'owner_1', productId: 'notelett',
title: 'Shared Note', body: '<p>Hello shared</p>',
});
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',

View File

@ -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<void> {
@ -73,6 +86,14 @@ export async function noteCollaboratorRoutes(app: FastifyInstance): Promise<void
const userId = getUserId(req);
const productId = getRequestProductId(req);
const { id: noteId } = req.params as { id: string };
const workspaceId = (req.query as { workspaceId?: string }).workspaceId;
if (!workspaceId) throw new BadRequestError('workspaceId query param required');
const access = await getNoteAccess(noteId, workspaceId, userId);
if (access !== 'owner') {
throw new NotFoundError('Note not found');
}
const collaborators = await collabRepo.listCollaboratorsForNote(noteId, productId);
return { items: collaborators, total: collaborators.length };
@ -117,7 +138,11 @@ export async function noteCollaboratorRoutes(app: FastifyInstance): Promise<void
}
const note = await noteRepo.getNote(noteId, parsed.data.workspaceId);
if (!note || note.userId !== userId || note.productId !== PRODUCT_ID) {
if (!note || note.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
const access = await getNoteAccess(noteId, parsed.data.workspaceId, userId);
if (!access) {
throw new NotFoundError('Note not found');
}
@ -142,6 +167,10 @@ export async function noteCollaboratorRoutes(app: FastifyInstance): Promise<void
if (!note || note.productId !== PRODUCT_ID) {
throw new NotFoundError('Note not found');
}
const access = await getNoteAccess(noteId, workspaceId, userId);
if (!access) {
throw new NotFoundError('Note not found');
}
const webOrigin = process.env.NEXT_PUBLIC_WEB_APP_ORIGIN || `http://localhost:3045`;

View File

@ -37,3 +37,7 @@ export async function listSharesForNote(
const items = await collection().findMany({ filter, sort: { createdAt: -1 }, limit: 20, offset: 0 });
return items;
}
export async function deleteShare(id: string, workspaceId: string): Promise<void> {
await collection().delete(id, workspaceId);
}

View File

@ -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),
});

View File

@ -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' });

View File

@ -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);
});
});

View File

@ -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<ReturnType<typeof getNoteAccess>>): 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 => {

View File

@ -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,
};
});

View File

@ -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() {
<div style={{ minHeight: "100vh", background: "var(--nl-bg-canvas)", color: "var(--nl-text-primary)", padding: "var(--nl-space-8)" }}>
<header style={{ maxWidth: 720, margin: "0 auto var(--nl-space-6)" }}>
<div className="badge" style={{ marginBottom: 12 }}>
Read-only share · {PRODUCT_NAME}
Read-only public share · {PRODUCT_NAME}
</div>
<h1 style={{ margin: 0, fontSize: "var(--nl-fs-2xl)" }}>{note?.title ?? (error ? "Unavailable" : "Loading…")}</h1>
</header>
@ -57,7 +58,7 @@ export default function SharedNotePage() {
{note ? (
<>
<p style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
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}
</p>
<div
className="input-shell"

View File

@ -0,0 +1,91 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
createNoteShareMock,
listNoteSharesMock,
revokeNoteShareMock,
exportNoteTextMock,
listCollaboratorsMock,
removeCollaboratorMock,
shareNoteWithUserMock,
toastMock,
} = vi.hoisted(() => ({
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: "<p>Body</p>" })),
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(<ShareDialog noteId="note-1" workspaceId="ws-1" noteTitle="Launch" onClose={vi.fn()} />);
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(<ShareDialog noteId="note-1" workspaceId="ws-1" noteTitle="Launch" onClose={vi.fn()} />);
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(<ShareDialog noteId="note-1" workspaceId="ws-1" noteTitle="Launch" onClose={vi.fn()} />);
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");
});
});

View File

@ -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<PublicNoteShare[]>([]);
const [collaborators, setCollaborators] = useState<CollaboratorRow[]>([]);
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" && (
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
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.
</p>
<Button type="button" disabled={loading} loading={loading} onClick={() => void handleCopyLink()} aria-label="Copy share link">
Copy Share Link
Create and Copy Link
</Button>
{publicLinks.length === 0 ? (
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
No active public links.
</p>
) : (
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
{publicLinks.map((link) => (
<div key={link.id} className="surface-muted" style={{ padding: "var(--nl-space-3)", display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap", alignItems: "center" }}>
<span style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
Expires {link.expiresAt ? new Date(link.expiresAt).toLocaleDateString() : "when revoked"}
</span>
<Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void handleRevokeLink(link.shareToken)}>
Revoke
</Button>
</div>
))}
</div>
)}
</div>
)}
@ -196,6 +264,26 @@ export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDi
<Button type="button" disabled={loading} loading={loading} onClick={() => void handleShareWithUser()} aria-label="Share with user">
Share
</Button>
{collaborators.length === 0 ? (
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
No direct collaborators yet.
</p>
) : (
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
{collaborators.map((collaborator) => (
<div key={collaborator.id ?? collaborator.sharedWithUserId} className="surface-muted" style={{ padding: "var(--nl-space-3)", display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap", alignItems: "center" }}>
<span style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
{collaborator.sharedWithUserId} · {collaborator.permission ?? "view"}
</span>
{collaborator.sharedWithUserId ? (
<Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void handleRemoveCollaborator(collaborator.sharedWithUserId!)}>
Remove
</Button>
) : null}
</div>
))}
</div>
)}
</div>
)}

View File

@ -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<{

View File

@ -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<void> {
const api = createNotesApiClient();
await api.fetch(
`/notes/${encodeURIComponent(noteId)}/shares/${encodeURIComponent(shareToken)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "DELETE" },
);
}
export interface NoteVersionRow {
id: string;
noteId: string;