diff --git a/apps/api/src/share-routes.test.mjs b/apps/api/src/share-routes.test.mjs index 14f6da9c..b73d2e45 100644 --- a/apps/api/src/share-routes.test.mjs +++ b/apps/api/src/share-routes.test.mjs @@ -336,6 +336,25 @@ const readCookieValue = (response, name) => { }; describe("memo share management", () => { + test("revocation retries succeed and keep the old link inaccessible", async () => { + const { sqlite, environment } = createDatabaseEnvironment(); + const app = createShareApp(environment); + const path = "/api/v1/memos/memo_source/share"; + expect((await app.request(`/api/public/shares/${sourceToken}`, {}, environment)).status).toBe(200); + for (let attempt = 0; attempt < 2; attempt++) { + const response = await app.request(path, { method: "DELETE" }, environment); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + expect((await (await app.request(path, {}, environment)).json()).share).toBeNull(); + expect((await app.request(`/api/public/shares/${sourceToken}`, {}, environment)).status).toBe(404); + } + expect((await app.request(`/api/public/shares/${targetToken}`, {}, environment)).status).toBe(200); + const unauthenticated = new Hono(); + registerMemoShareRoutes(unauthenticated); + expect((await unauthenticated.request(path, { method: "DELETE" }, environment)).status).toBe(403); + sqlite.close(); + }); + test("lists only live shares from the signed-in workspace without exposing passwords", async () => { const { sqlite, environment } = createDatabaseEnvironment(); sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)").run("ws_other", "Other workspace"); diff --git a/apps/api/src/share-routes.ts b/apps/api/src/share-routes.ts index 11485c28..8dbc1007 100644 --- a/apps/api/src/share-routes.ts +++ b/apps/api/src/share-routes.ts @@ -475,7 +475,8 @@ export const registerMemoShareRoutes = (app: Hono) => { const existing = await c.env.storage.db.prepare( `SELECT memo_id FROM memo_shares WHERE memo_id = ? AND workspace_id = ?` ).bind(memoId, workspaceId).first<{ memo_id: string }>(); - if (!existing) return notFound(c, "Active share not found"); + // A retry after successful revocation already has the requested state. + if (!existing) return c.json({ ok: true }); await c.env.storage.db.prepare( `DELETE FROM memo_shares WHERE memo_id = ? AND workspace_id = ?` diff --git a/apps/web/src/components/MemoListPane.tsx b/apps/web/src/components/MemoListPane.tsx index e9ba87fe..533c7b24 100644 --- a/apps/web/src/components/MemoListPane.tsx +++ b/apps/web/src/components/MemoListPane.tsx @@ -11,6 +11,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; @@ -44,6 +45,7 @@ import { TagX, Image as ImageIcon, Share2, + Globe, FileDown, FileCode2, FileUp, @@ -517,6 +519,17 @@ export const MemoListPane = ({ }) => { const { t } = useTranslation(); const [memoContextMenu, setMemoContextMenu] = useState(null); + const contextShareMemoId = memoContextMenu?.memo.id; + const contextShareQuery = useQuery({ + queryKey: ["memo-share", contextShareMemoId ?? ""], + queryFn: () => { + if (!contextShareMemoId) throw new Error("Memo share query requires a memo id"); + return api.getMemoShare(contextShareMemoId); + }, + enabled: Boolean(contextShareMemoId && !isLocalMemoId(contextShareMemoId)), + retry: false, + staleTime: 30_000, + }); const [listDensity, setListDensity] = useState(() => readMemoListDensityPreference()); const [lastSelectedMemoId, setLastSelectedMemoId] = useState(null); const [moveTargetNotebookId, setMoveTargetNotebookId] = useState(""); @@ -1671,8 +1684,8 @@ export const MemoListPane = ({ disabled={isLocalMemoId(memoContextMenu.memo.id)} onClick={() => requestContextDocumentAction("share")} > - - {t(isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync" : "sharing.action")} + {contextShareQuery.data?.share ? : } + {t(isLocalMemoId(memoContextMenu.memo.id) ? "sharing.afterSync" : contextShareQuery.data?.share ? "sharing.manage" : "sharing.action")} {!memoContextMenu.memo.diagramKind && !memoContextMenu.memo.structuredTable && !memoContextMenu.memo.infographic && !memoContextMenu.memo.poster && ( - - {t("sharing.title")} + {share ? : } + {t(share ? "sharing.manage" : "sharing.title")}