From f53d377766cdf4f61b0d02c74070c2bc170a74e8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 15:01:53 -0400 Subject: [PATCH] sharing: share a note from the web, at view or edit, with anyone on the instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//shares and DELETE …/shares/, 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 --- alembic/versions/0034_share_constraints.py | 50 +++++++ frontend/src/adapters/local.ts | 11 +- frontend/src/adapters/repo.ts | 28 ++++ frontend/src/adapters/rest.ts | 12 ++ frontend/src/components/AppShell.vue | 2 + frontend/src/components/FilterBar.vue | 15 ++ frontend/src/components/Icon.vue | 1 + frontend/src/components/NoteCard.vue | 54 ++++++- frontend/src/components/NoteEditor.vue | 60 ++++++-- frontend/src/components/ShareDialog.vue | 163 +++++++++++++++++++++ frontend/src/notes/facets.ts | 3 + frontend/src/notes/sharing.test.ts | 36 +++++ frontend/src/notes/sharing.ts | 14 ++ frontend/src/stores/notes.ts | 21 +++ frontend/src/stores/ui.ts | 13 ++ src/inkwell/acl.py | 50 ++++++- src/inkwell/app.py | 2 + src/inkwell/notes/__init__.py | 56 ++++--- src/inkwell/notes/helpers.py | 25 +++- src/inkwell/notes/serialize.py | 54 +++++-- src/inkwell/retention.py | 3 + src/inkwell/saved_filters.py | 1 + src/inkwell/shares_api.py | 127 ++++++++++++++++ tests/test_integration.py | 160 +++++++++++++++++++- 24 files changed, 911 insertions(+), 50 deletions(-) create mode 100644 alembic/versions/0034_share_constraints.py create mode 100644 frontend/src/components/ShareDialog.vue create mode 100644 frontend/src/notes/sharing.test.ts create mode 100644 frontend/src/notes/sharing.ts create mode 100644 src/inkwell/shares_api.py diff --git a/alembic/versions/0034_share_constraints.py b/alembic/versions/0034_share_constraints.py new file mode 100644 index 0000000..10181f2 --- /dev/null +++ b/alembic/versions/0034_share_constraints.py @@ -0,0 +1,50 @@ +"""shares: one grant per note and person, and only the permissions the app knows + +Revision ID: 0034 +Revises: 0033 +Create Date: 2026-10-07 + +Nothing wrote a share until #5174, so the table never needed these. Now the Share +dialog does: +- A unique index on (resource_type, resource_id, shared_with_user_id) where a user is + the target, so sharing a note with someone twice updates the one grant rather than + stacking two (the same for a group, ahead of step 15). +- A check that `permission` is `view` or `edit`. +- An index on `shared_with_user_id` for "Shared with me". + +## Downgrade + +Drops the indexes and the check. The rows stay. +""" +from alembic import op + +revision = "0034" +down_revision = "0033" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "uq_shares_resource_user", + "shares", + ["resource_type", "resource_id", "shared_with_user_id"], + unique=True, + postgresql_where="shared_with_user_id IS NOT NULL", + ) + op.create_index( + "uq_shares_resource_group", + "shares", + ["resource_type", "resource_id", "shared_with_group_id"], + unique=True, + postgresql_where="shared_with_group_id IS NOT NULL", + ) + op.create_index("ix_shares_user", "shares", ["shared_with_user_id"]) + op.create_check_constraint("ck_shares_permission", "shares", "permission IN ('view', 'edit')") + + +def downgrade() -> None: + op.drop_constraint("ck_shares_permission", "shares", type_="check") + op.drop_index("ix_shares_user", table_name="shares") + op.drop_index("uq_shares_resource_group", table_name="shares") + op.drop_index("uq_shares_resource_user", table_name="shares") diff --git a/frontend/src/adapters/local.ts b/frontend/src/adapters/local.ts index d2a54fa..e5ccee6 100644 --- a/frontend/src/adapters/local.ts +++ b/frontend/src/adapters/local.ts @@ -16,7 +16,7 @@ import type { Device } from "../stores/devices"; import type { TitleEntry } from "../stores/titles"; import type { User } from "../stores/session"; import type { PublicConfig } from "../stores/config"; -import type { DeviceToken, ImportResult, Repo } from "./repo"; +import type { DeviceToken, ImportResult, Member, NoteShare, Repo } from "./repo"; const NEEDS_SERVER = "That's not available offline — connect a server to use it."; @@ -91,4 +91,13 @@ export const local: Repo = { remove: (id) => invoke("saved_filters_remove", { id }), rename: (id, name) => invoke("saved_filters_rename", { id, name }), }, + + // Sharing is between accounts on a server; the desktop gets it with sync (#5175). + // The Share controls are not shown here, so these only answer a stray call. + shares: { + directory: () => Promise.resolve([]), + list: () => Promise.resolve([]), + share: () => Promise.reject(new Error(NEEDS_SERVER)), + unshare: () => Promise.reject(new Error(NEEDS_SERVER)), + }, }; diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index 367ad1e..0705674 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -46,6 +46,24 @@ export interface ChecklistItemChanges { } +// ---- sharing (#5174) --------------------------------------------------------- + +/** A person on this instance, as the member directory shows them. */ +export interface Member { + id: string; + display_name: string; + email: string; +} + +export type SharePermission = "view" | "edit"; + +export interface NoteShare { + id: string; + member: Member; + permission: SharePermission; + created_at: string | null; +} + export interface ImportResult { source: string; imported: number; @@ -116,6 +134,15 @@ export interface NotesRepo { titles(): Promise; } +export interface SharesRepo { + /** Everyone on the instance but you. */ + directory(): Promise; + list(noteId: string): Promise; + /** Share with one member, or change their permission. Answers the note's shares. */ + share(noteId: string, userId: string, permission: SharePermission): Promise; + unshare(noteId: string, shareId: string): Promise; +} + export interface SavedFiltersRepo { list(): Promise; create(name: string, params: NoteFacets): Promise; @@ -130,4 +157,5 @@ export interface Repo { labels: LabelsRepo; notes: NotesRepo; savedFilters: SavedFiltersRepo; + shares: SharesRepo; } diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index 015b91b..d3fd680 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -15,7 +15,9 @@ import type { PublicConfig } from "../stores/config"; import type { DeviceToken, ImportResult, + Member, NoteChanges, + NoteShare, NoteCreateInput, NoteListQuery, ChecklistItemChanges, @@ -35,6 +37,7 @@ function notesQuery(q: NoteListQuery): string { if (q.facets?.has_attachment) params.set("has_attachment", "true"); if (q.facets?.created_after) params.set("created_after", q.facets.created_after); if (q.facets?.created_before) params.set("created_before", q.facets.created_before); + if (q.facets?.shared) params.set("shared", q.facets.shared); if (q.sort) params.set("sort", q.sort); return params.toString(); } @@ -115,4 +118,13 @@ export const rest: Repo = { remove: (id) => api.del(`/api/saved-filters/${id}`), rename: (id, name) => api.patch(`/api/saved-filters/${id}`, { name }), }, + + shares: { + directory: async () => (await api.get<{ members: Member[] }>("/api/users/directory")).members, + list: async (noteId) => (await api.get<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`)).shares, + share: async (noteId, userId, permission) => + (await api.post<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`, { user_id: userId, permission })).shares, + unshare: async (noteId, shareId) => + (await api.del<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares/${shareId}`)).shares, + }, }; diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index c8480bd..28f2507 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -13,6 +13,7 @@ import Icon from "./Icon.vue"; import ExportNotes from "./ExportNotes.vue"; import ImportNotes from "./ImportNotes.vue"; import LabelsModal from "./LabelsModal.vue"; +import ShareDialog from "./ShareDialog.vue"; import { isDesktop } from "../desktop/bridge"; import { useLensName } from "../composables/useLensName"; import { facetsToQuery } from "../notes/facets"; @@ -552,6 +553,7 @@ async function signOut() { + Has attachment +
diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index 478ec00..8ba0586 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -29,6 +29,7 @@ const paths: Record = { paperclip: '', link: '', filter: '', + users: '', copy: '', }; diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 5836313..251cb37 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -16,6 +16,9 @@ import { } from "../composables/useCardDrag"; import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime"; import { useConfigStore } from "../stores/config"; +import { useUiStore } from "../stores/ui"; +import { canEditText, isOwnNote } from "../notes/sharing"; +import { isDesktop } from "../desktop/bridge"; const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>(); const emit = defineEmits<{ @@ -32,6 +35,21 @@ const emit = defineEmits<{ }>(); const notes = useNotesStore(); const config = useConfigStore(); +const ui = useUiStore(); + +// --- Sharing (#5174). Someone else's note shows who shared it and offers none of +// the owner's controls; at `view` its checkboxes are inert too. Sharing needs a +// server, so the offline desktop has no Share button (its notes are all its own). --- +const own = computed(() => isOwnNote(props.note)); +const editable = computed(() => canEditText(props.note)); +const canShare = computed(() => own.value && !isDesktop()); +const sharedLine = computed(() => { + if (!own.value) { + const who = props.note.shared_by?.display_name || "someone"; + return props.note.permission === "view" ? `Shared by ${who} · view only` : `Shared by ${who}`; + } + return props.note.shared ? "Shared" : ""; +}); // --- Retention countdown. A note in Trash is on a clock, and the card is the only // place someone browsing Trash would ever find that out in time to restore it. @@ -105,7 +123,8 @@ const root = ref(null); // Pointer rather than native HTML5 drag-and-drop because that API never fires from // touch — on a phone this did nothing at all. One code path now covers mouse, touch // and stylus. See composables/useCardDrag.ts for why the state is shared. --- -const canDrag = () => !!props.reorderable && !props.note.trashed; +// Board order is the owner's; a shared note's place is the recipient's from #5176. +const canDrag = () => !!props.reorderable && !props.note.trashed && own.value; const dragging = computed(() => draggingId.value === props.note.id); const dragOver = computed(() => overId.value === props.note.id); @@ -308,7 +327,12 @@ const tagColors = computed>(() => { blank and the link is never unreachable. -->
- +

>(() => { two paragraphs instead of always after them. Rendering both would have shown every list twice. --> -

+
+ + + {{ sharedLine }} + +
+ + +
+
-