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

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:
2026-10-07 15:01:53 -04:00
co-authored by Claude Opus 5.5
parent f100e5ef85
commit f53d377766
24 changed files with 911 additions and 50 deletions
+51 -9
View File
@@ -11,6 +11,9 @@ import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { labelChipClasses } from "../notes/colors";
import { canEditText, isOwnNote } from "../notes/sharing";
import { useUiStore } from "../stores/ui";
import { isDesktop } from "../desktop/bridge";
import {
afterEnter,
type EditorBlock,
@@ -114,6 +117,19 @@ const liveNote = computed<Note>(() =>
);
const bodyPlaceholder = "Take a note…";
// Sharing (#5174). Someone else's note: at `edit` its text and checklist are theirs to
// change and nothing else is; at `view` it is read-only. The server enforces both —
// this only stops the editor offering what would be refused.
const ui = useUiStore();
const own = computed(() => isOwnNote(liveNote.value));
const editable = computed(() => canEditText(liveNote.value));
const canShare = computed(() => own.value && !isCreate.value && !isDesktop());
const sharedLine = computed(() => {
if (own.value) return "";
const who = liveNote.value.shared_by?.display_name || "someone";
return editable.value ? `Shared by ${who} · you can edit the text` : `Shared by ${who} · view only`;
});
// Keep local state in sync when the edited note changes (modal reused for another note).
watch(
() => props.note,
@@ -353,6 +369,7 @@ function onProseInput(index: number, e: Event): void {
* however many lines became checkboxes and would otherwise keep its old height.
*/
function onProseBlur(index: number): void {
if (!editable.value) return;
const promoted = promoteTasks(blocks.value, index);
if (promoted === blocks.value) return;
blocks.value = promoted;
@@ -369,6 +386,7 @@ function onProseKeydown(e: KeyboardEvent): void {
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */
function onTaskEnter(index: number): void {
if (!editable.value) return;
const next = afterEnter(blocks.value, index);
blocks.value = next.blocks;
void focusBlock(next.focus);
@@ -383,6 +401,7 @@ function onTaskEnter(index: number): void {
* only one of them can offer.
*/
function onTaskBackspace(index: number, e: KeyboardEvent): void {
if (!editable.value) return;
const el = e.target as HTMLInputElement;
if (el.value !== "" || el.selectionStart !== 0) return;
e.preventDefault();
@@ -487,6 +506,7 @@ async function onFileChange(e: Event) {
input.value = "";
}
async function onPaste(e: ClipboardEvent) {
if (!own.value) return;
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith("image/"));
const file = item?.getAsFile();
if (file) {
@@ -563,12 +583,17 @@ function revPreview(rev: NoteRevision): string {
@paste="onPaste"
>
<div class="flex flex-col gap-2 p-4">
<p v-if="sharedLine" class="flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400">
<Icon name="users" class="!h-3.5 !w-3.5" />
{{ sharedLine }}
</p>
<div v-if="liveNote.attachments.length" class="flex flex-wrap items-center gap-2">
<template v-for="att in liveNote.attachments" :key="att.id">
<!-- Image → inline thumbnail -->
<div v-if="attKind(att.mime) === 'image'" class="group/att relative">
<img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" />
<button
v-if="own"
type="button"
class="hover-reveal absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
aria-label="Remove attachment"
@@ -584,6 +609,7 @@ function revPreview(rev: NoteRevision): string {
>
<audio controls :src="att.url" class="h-8 max-w-[220px]"></audio>
<button
v-if="own"
type="button"
class="text-neutral-400 hover:text-red-500"
aria-label="Remove attachment"
@@ -603,6 +629,7 @@ function revPreview(rev: NoteRevision): string {
<span class="max-w-[160px] truncate">{{ att.filename || "file" }}</span>
<span v-if="att.size" class="text-neutral-400">{{ fmtSize(att.size) }}</span>
<button
v-if="own"
type="button"
class="ml-1 text-neutral-400 hover:text-red-500"
aria-label="Remove attachment"
@@ -631,7 +658,7 @@ function revPreview(rev: NoteRevision): string {
v-for="p in liveNote.previews"
:key="p.id"
:preview="p"
:removable="!liveNote.trashed"
:removable="!liveNote.trashed && own"
@remove="notes.deletePreview(liveNote.id, p.id)"
/>
</div>
@@ -646,6 +673,7 @@ function revPreview(rev: NoteRevision): string {
type="checkbox"
class="h-4 w-4 shrink-0 accent-brand"
:checked="block.checked"
:disabled="!editable"
:aria-label="block.text || 'Checklist item'"
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
/>
@@ -653,6 +681,7 @@ function revPreview(rev: NoteRevision): string {
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
type="text"
:readonly="!editable"
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
:class="block.checked ? 'text-neutral-400 line-through' : ''"
@input="setText(i, ($event.target as HTMLInputElement).value)"
@@ -660,6 +689,7 @@ function revPreview(rev: NoteRevision): string {
@keydown.backspace="onTaskBackspace(i, $event)"
/>
<button
v-if="editable"
type="button"
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item"
@@ -673,7 +703,8 @@ function revPreview(rev: NoteRevision): string {
:ref="(el) => setBlockEl(block.id, el)"
:value="block.text"
rows="1"
:placeholder="i === 0 ? bodyPlaceholder : ''"
:readonly="!editable"
:placeholder="i === 0 && editable ? bodyPlaceholder : ''"
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onProseInput(i, $event)"
@keydown="onProseKeydown"
@@ -707,7 +738,7 @@ function revPreview(rev: NoteRevision): string {
</span>
</div>
<div v-if="richEnabled" class="flex items-center gap-2 pt-1">
<div v-if="richEnabled && own" class="flex items-center gap-2 pt-1">
<Icon name="bell" class="text-neutral-400" />
<input
type="datetime-local"
@@ -725,7 +756,7 @@ function revPreview(rev: NoteRevision): string {
</button>
</div>
<div v-if="richEnabled && liveNote.remind_at" class="flex flex-wrap items-center gap-2 pl-6 text-xs">
<div v-if="richEnabled && own && liveNote.remind_at" class="flex flex-wrap items-center gap-2 pl-6 text-xs">
<label class="text-neutral-400">Repeat</label>
<select
:value="liveNote.recurrence ?? ''"
@@ -785,7 +816,7 @@ function revPreview(rev: NoteRevision): string {
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<div class="flex items-center gap-0.5">
<button
v-if="richEnabled && !liveNote.trashed"
v-if="richEnabled && !liveNote.trashed && own"
type="button"
class="icon-btn"
title="Attach a file"
@@ -796,7 +827,7 @@ function revPreview(rev: NoteRevision): string {
</button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button
v-if="!liveNote.trashed"
v-if="!liveNote.trashed && editable"
type="button"
class="icon-btn"
title="Add a checklist"
@@ -806,12 +837,23 @@ function revPreview(rev: NoteRevision): string {
<Icon name="checkbox" />
</button>
<LabelPicker
v-if="richEnabled && !liveNote.trashed"
v-if="richEnabled && !liveNote.trashed && own"
:model-value="labelList"
@update:model-value="onLabelsChange"
/>
<button
v-if="!isCreate"
v-if="canShare && !liveNote.trashed"
type="button"
class="icon-btn"
:class="liveNote.shared ? 'text-brand-700 dark:text-brand' : ''"
title="Share"
aria-label="Share"
@click="ui.openShare(liveNote.id)"
>
<Icon name="users" />
</button>
<button
v-if="!isCreate && own"
type="button"
class="icon-btn"
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
@@ -822,7 +864,7 @@ function revPreview(rev: NoteRevision): string {
>
<Icon name="history" />
</button>
<template v-if="!isCreate && !liveNote.trashed">
<template v-if="!isCreate && !liveNote.trashed && own">
<button
type="button"
class="icon-btn"