sharing: share a note from the web, at view or edit, with anyone on the instance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m6s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m33s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m6s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m33s
The ACL has gated every read since M0, but nothing could write a share. Server: - shares_api: GET /api/users/directory (everyone but you, signed-in only), GET/POST /api/notes/<id>/shares and DELETE …/shares/<share_id>, owner only. Sharing again with the same person changes the permission (ON CONFLICT on the new unique index). - acl.visible_to_user takes permission=; granted_to and shared_ids feed the serializer. - Edit covers body and checklist (_get_editable). Everything else stays _get_owned. A view share's write is a 404 like a stranger's (#1984). An editor's PATCH naming anything but body is a 403. - Serialized notes carry permission, shared and shared_by. A recipient never gets the owner's labels, and a #tag an editor types files under the owner's (it always went to note.owner_id). - ?shared=with_me, also allowed in saved views. Trash and reminders are the owner's. purge_note drops the note's shares. - Migration 0034: one share per note and person (and per group), permission limited to view and edit, an index for "shared with me". Web: - ShareDialog (one, mounted by the shell): pick a member, Can view or Can edit, change or remove existing shares, with loading, error and empty states. - Card: "Shared by X" or "Shared" chip; owner-only actions and reminder buttons hidden for recipients; checkboxes inert at view. - Editor: read-only at view; text and checklist only at edit; Share button for the owner. - FilterBar: Shared with me. Repo seam gains `shares`; the offline desktop shows none of it (#5175 brings sharing there). Tests: owner, recipient and stranger across reads, every write at view and edit, tag filing, unshare, trash, delete and validation; web unit tests for the facet and permission helpers. #5174. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
|
||||
if (after) f.created_after = after;
|
||||
const before = one(q.created_before);
|
||||
if (before) f.created_before = before;
|
||||
if (one(q.shared) === "with_me") f.shared = "with_me";
|
||||
return f;
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
|
||||
if (f.has_attachment) q.has_attachment = "true";
|
||||
if (f.created_after) q.created_after = f.created_after;
|
||||
if (f.created_before) q.created_before = f.created_before;
|
||||
if (f.shared) q.shared = f.shared;
|
||||
return q;
|
||||
}
|
||||
|
||||
@@ -44,5 +46,6 @@ export function facetCount(f: NoteFacets): number {
|
||||
if (f.has_reminder) n++;
|
||||
if (f.has_attachment) n++;
|
||||
if (f.created_after || f.created_before) n++;
|
||||
if (f.shared) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { canEditText, isOwnNote } from "./sharing";
|
||||
import { facetCount, facetsFromQuery, facetsToQuery } from "./facets";
|
||||
import type { Note } from "../stores/notes";
|
||||
|
||||
const note = (extra: Partial<Note>): Note => ({ ...({} as Note), ...extra });
|
||||
|
||||
describe("how a note is held", () => {
|
||||
it("is your own when the source says nothing of sharing", () => {
|
||||
expect(isOwnNote(note({}))).toBe(true);
|
||||
expect(canEditText(note({}))).toBe(true);
|
||||
});
|
||||
|
||||
it("lets an edit share change the text but not own the note", () => {
|
||||
expect(isOwnNote(note({ permission: "edit" }))).toBe(false);
|
||||
expect(canEditText(note({ permission: "edit" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a view share read-only", () => {
|
||||
expect(canEditText(note({ permission: "view" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Shared with me facet", () => {
|
||||
it("round-trips through the URL and counts as one filter", () => {
|
||||
const facets = facetsFromQuery({ shared: "with_me" });
|
||||
expect(facets).toEqual({ shared: "with_me" });
|
||||
expect(facetsToQuery(facets)).toEqual({ shared: "with_me" });
|
||||
expect(facetCount(facets)).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores a value it doesn't know", () => {
|
||||
expect(facetsFromQuery({ shared: "by_me" })).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Note } from "../stores/notes";
|
||||
|
||||
// How the signed-in person holds a note (#5174). The owner does everything; someone it
|
||||
// is shared with at `edit` changes the body and checklist; at `view`, nothing. A note
|
||||
// with no permission came from a source without sharing (the offline desktop): it is
|
||||
// the person's own.
|
||||
|
||||
export function isOwnNote(note: Note): boolean {
|
||||
return (note.permission ?? "owner") === "owner";
|
||||
}
|
||||
|
||||
export function canEditText(note: Note): boolean {
|
||||
return (note.permission ?? "owner") !== "view";
|
||||
}
|
||||
Reference in New Issue
Block a user