fix(sharing): harden note share revocation flows
This commit is contained in:
parent
5101c847da
commit
e71febe51a
@ -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',
|
||||
|
||||
@ -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`;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
91
web/src/components/ShareDialog.test.tsx
Normal file
91
web/src/components/ShareDialog.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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<{
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user