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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user