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:
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
import type { Member, NoteShare, SharePermission } from "../adapters/repo";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import BaseModal from "./BaseModal.vue";
|
||||
import Icon from "./Icon.vue";
|
||||
|
||||
// Share a note with other people on this instance (#5174). The owner picks a member
|
||||
// from the directory and says whether they may only read it or also change its text.
|
||||
// Only the owner opens this; the server refuses everyone else regardless.
|
||||
|
||||
const props = defineProps<{ noteId: string }>();
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
const notes = useNotesStore();
|
||||
|
||||
const PERMISSIONS: { value: SharePermission; label: string }[] = [
|
||||
{ value: "view", label: "Can view" },
|
||||
{ value: "edit", label: "Can edit" },
|
||||
];
|
||||
|
||||
const members = ref<Member[]>([]);
|
||||
const shares = ref<NoteShare[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
const pick = ref("");
|
||||
const permission = ref<SharePermission>("view");
|
||||
|
||||
/** The people it isn't shared with yet. */
|
||||
const available = computed(() => {
|
||||
const taken = new Set(shares.value.map((s) => s.member.id));
|
||||
return members.value.filter((m) => !taken.has(m.id));
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
[members.value, shares.value] = await Promise.all([repo.shares.directory(), repo.shares.list(props.noteId)]);
|
||||
} catch (e) {
|
||||
loadError.value = errorMessage(e, "Couldn't load who this is shared with.");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run(fn: () => Promise<NoteShare[]>, fallback: string) {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
shares.value = await fn();
|
||||
notes.setShared(props.noteId, shares.value.length > 0);
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e, fallback);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!pick.value) return;
|
||||
const userId = pick.value;
|
||||
await run(() => repo.shares.share(props.noteId, userId, permission.value), "Couldn't share the note.");
|
||||
if (!error.value) pick.value = "";
|
||||
}
|
||||
|
||||
function change(share: NoteShare, next: SharePermission) {
|
||||
void run(() => repo.shares.share(props.noteId, share.member.id, next), "Couldn't change that.");
|
||||
}
|
||||
|
||||
function remove(share: NoteShare) {
|
||||
void run(() => repo.shares.unshare(props.noteId, share.id), "Couldn't stop sharing.");
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
"rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:border-neutral-700 dark:bg-neutral-800";
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal panel-class="w-full max-w-md shadow-xl" @close="emit('close')">
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
||||
<h2 class="text-sm font-semibold">Share note</h2>
|
||||
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<p v-if="loading" class="py-4 text-center text-sm text-neutral-400">Loading…</p>
|
||||
|
||||
<div v-else-if="loadError" class="py-4 text-center">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ loadError }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 rounded-md border border-neutral-300 px-3 py-1 text-sm hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
@click="load"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="!members.length" class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
No one else has an account here yet. An admin can invite people from Settings.
|
||||
</p>
|
||||
|
||||
<form v-else-if="available.length" class="flex flex-wrap items-center gap-2" @submit.prevent="add">
|
||||
<select v-model="pick" :class="[selectClass, 'min-w-0 flex-1']" aria-label="Person" :disabled="busy">
|
||||
<option value="" disabled>Choose someone…</option>
|
||||
<option v-for="m in available" :key="m.id" :value="m.id">{{ m.display_name }} ({{ m.email }})</option>
|
||||
</select>
|
||||
<select v-model="permission" :class="selectClass" aria-label="Permission" :disabled="busy">
|
||||
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60"
|
||||
:disabled="busy || !pick"
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" role="alert" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Shared with</p>
|
||||
<p v-if="!shares.length" class="text-sm text-neutral-400">Only you.</p>
|
||||
<ul v-else class="flex flex-col gap-1">
|
||||
<li v-for="s in shares" :key="s.id" class="flex items-center gap-2 rounded-lg px-1 py-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm text-neutral-800 dark:text-neutral-100">{{ s.member.display_name }}</p>
|
||||
<p class="truncate text-xs text-neutral-400">{{ s.member.email }}</p>
|
||||
</div>
|
||||
<select
|
||||
:value="s.permission"
|
||||
:class="selectClass"
|
||||
:aria-label="`Permission for ${s.member.display_name}`"
|
||||
:disabled="busy"
|
||||
@change="change(s, ($event.target as HTMLSelectElement).value as SharePermission)"
|
||||
>
|
||||
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:title="`Stop sharing with ${s.member.display_name}`"
|
||||
:aria-label="`Stop sharing with ${s.member.display_name}`"
|
||||
:disabled="busy"
|
||||
@click="remove(s)"
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user