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 }} + +
+ + +
+
-