Skip to content
Merged
31 changes: 31 additions & 0 deletions .github/workflows/share-password-copy-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Share Password Copy CI

on:
pull_request:
paths:
- "frontend/src/components/ShareModal.tsx"
- "frontend/src/lib/shareCopySession.ts"
- "frontend/src/lib/__tests__/shareCopySession.test.ts"
- "frontend/src/lib/__tests__/shareModalPasswordCopy.test.tsx"
- "frontend/src/i18n/coverageTranslations.ts"
- "frontend/src/types/index.ts"
- ".github/workflows/share-password-copy-ci.yml"

jobs:
share-password-copy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- name: Clipboard credential lifecycle and ShareModal UI
run: npm run test:run -- src/lib/__tests__/shareCopySession.test.ts src/lib/__tests__/shareModalPasswordCopy.test.tsx
- name: TypeScript
run: npx tsc -b
4 changes: 3 additions & 1 deletion docs/tutorials/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ nowen-note 支持 4 档分享权限:

## 分享链接

配置完成后,点击「复制链接」获取分享 URL。
配置完成后,点击「复制链接」获取分享 URL。对于**本次弹窗中新建或重设过访问密码**的分享,还可以点击「复制链接和密码」,将笔记标题、链接和访问密码一起复制给受信任的接收方。

历史密码仅以哈希方式保存在服务端,无法取回;重新打开分享弹窗后,如果需要连密码一起复制,请先重新设置访问密码。复制到剪贴板的内容包含访问凭据,请谨慎发送。

将链接发送给需要查看的人,他们不需要登录就能访问(除非设置了「需登录」权限)。

Expand Down
47 changes: 39 additions & 8 deletions frontend/src/components/ShareModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { confirm } from "@/components/ui/confirm";
import { api } from "@/lib/api";
import { copyText } from "@/lib/clipboard";
import { ShareCopySession, formatShareWithPassword } from "@/lib/shareCopySession";
import {
buildPublicWebUrl,
resolvePublicWebOrigin,
Expand Down Expand Up @@ -52,6 +53,11 @@ export default function ShareModal({ noteId, noteTitle, initialShareId, onClose
const [originSaving, setOriginSaving] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const initialShareAppliedRef = useRef<string | null>(null);
const copySessionRef = useRef(new ShareCopySession());

// Never persist plaintext share passwords in storage or across dialogs/notes.
useEffect(() => () => copySessionRef.current.clear(), []);
useEffect(() => { copySessionRef.current.clear(); }, [noteId]);

const publicOrigin = resolvePublicWebOrigin({
runtimeOrigin: siteConfig.publicWebOrigin,
Expand Down Expand Up @@ -155,19 +161,22 @@ export default function ShareModal({ noteId, noteTitle, initialShareId, onClose
maxViews: parsedMax,
};
if (editingId) {
await api.updateShare(editingId, {
const updated = await api.updateShare(editingId, {
...common,
...(password.trim() ? { password: password.trim() } : {}),
});
if (password.trim()) copySessionRef.current.remember(updated, password);
else if (!updated.hasPassword) copySessionRef.current.forget(updated.id);
toast.success(t("shareUi.shareUpdated"));
} else {
await api.createShare({
const created = await api.createShare({
noteId,
permission,
password: password.trim() || undefined,
expiresAt: common.expiresAt || undefined,
maxViews: parsedMax || undefined,
});
if (password.trim()) copySessionRef.current.remember(created, password);
if (publicOrigin.requiresAnonymousCheck) {
toast.warning(t("shareUi.createdVerify"));
} else {
Expand Down Expand Up @@ -200,22 +209,44 @@ export default function ShareModal({ noteId, noteTitle, initialShareId, onClose
}
};

const mutate = async (action: () => Promise<unknown>, success: string) => {
try { await action(); toast.success(success); await loadShares(); }
catch (error: any) { toast.error(error?.message || t("shareUi.operationFailed")); }
const copyWithPassword = async (share: Share) => {
const knownPassword = copySessionRef.current.get(share);
if (!knownPassword) {
editShare(share);
toast.info(t("shareUi.passwordMustReset"));
return;
}
const text = formatShareWithPassword(noteTitle, shareUrl(share.shareToken), knownPassword, {
note: t("shareUi.copyNoteLabel"),
link: t("shareUi.copyLinkLabel"),
password: t("shareUi.copyPasswordLabel"),
});
const ok = await copyText(text);
if (!ok) { toast.error(t("shareUi.copyFailed")); return; }
if (publicOrigin.requiresAnonymousCheck) toast.warning(t("shareUi.copiedVerify"));
else toast.success(t("shareUi.copiedWithPassword"));
};

const mutate = async (action: () => Promise<unknown>, success: string): Promise<boolean> => {
try { await action(); toast.success(success); await loadShares(); return true; }
catch (error: unknown) { toast.error(error instanceof Error ? error.message : t("shareUi.operationFailed")); return false; }
};

const rotate = async (share: Share) => {
if (!await confirm({ title: t("shareUi.rotateTitle"), description: t("shareUi.rotateWarning") })) return;
await mutate(() => api.updateShare(share.id, { rotateToken: true }), t("shareUi.rotated"));
if (await mutate(() => api.updateShare(share.id, { rotateToken: true }), t("shareUi.rotated"))) {
copySessionRef.current.forget(share.id);
}
};
const resetViews = async (share: Share) => {
if (!await confirm({ title: t("shareUi.resetTitle"), description: t("shareUi.resetDescription") })) return;
await mutate(() => api.updateShare(share.id, { resetViews: true }), t("shareUi.resetSuccess"));
};
const remove = async (share: Share) => {
if (!await confirm({ title: t("shareUi.deleteTitle"), description: t("shareUi.deleteDescription"), danger: true })) return;
await mutate(() => api.deleteShare(share.id), t("shareUi.deleted"));
if (await mutate(() => api.deleteShare(share.id), t("shareUi.deleted"))) {
copySessionRef.current.forget(share.id);
}
};

const riskMessage = publicOrigin.isLikelyProtectedGateway
Expand Down Expand Up @@ -290,7 +321,7 @@ export default function ShareModal({ noteId, noteTitle, initialShareId, onClose
const url = shareUrl(share.shareToken);
return <article key={share.id} className={cn("rounded-xl border border-app-border p-3", !active && "opacity-60")}>
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="text-sm font-medium">{permissionLabel(share.permission)}</span><span className={cn("rounded-full px-2 py-0.5 text-[10px]", active ? "bg-emerald-500/10 text-emerald-600" : "bg-app-hover text-tx-tertiary")}>{active ? t("shareUi.active") : t("shareUi.inactive")}</span>{share.hasPassword && <span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-600">{t("shareUi.password")}</span>}</div><p className="mt-1 truncate text-xs text-tx-tertiary">{url}</p><p className="mt-1 text-[11px] text-tx-tertiary">{t("shareUi.viewSessions", { total: share.viewCount || 0 })}{share.maxViews ? ` / ${share.maxViews}` : ""}{share.expiresAt ? t("shareUi.expiresOn", { date: new Date(share.expiresAt).toLocaleString(i18n.language) }) : ""}</p></div><Shield size={16} className="shrink-0 text-tx-tertiary" /></div>
<div className="mt-3 flex flex-wrap gap-1.5"><Button size="sm" variant="outline" onClick={() => copy(url, share.id)}>{copied === share.id ? <Check size={13} /> : <Copy size={13} />}<span className="ml-1">{t("shareUi.copy")}</span></Button><Button size="sm" variant="outline" onClick={() => window.open(url, "_blank", "noopener,noreferrer")}><ExternalLink size={13} /></Button><Button size="sm" variant="outline" onClick={() => editShare(share)}><Pencil size={13} className="mr-1" />{t("shareUi.edit")}</Button><Button size="sm" variant="outline" onClick={() => resetViews(share)}><RotateCcw size={13} className="mr-1" />{t("shareUi.reset")}</Button><Button size="sm" variant="outline" onClick={() => rotate(share)}><RefreshCw size={13} className="mr-1" />{t("shareUi.replaceLink")}</Button><Button size="sm" variant="outline" onClick={() => mutate(() => api.updateShare(share.id, { isActive: active ? 0 : 1 }), active ? t("shareUi.disabledMessage") : t("shareUi.enabledMessage"))}>{active ? t("shareUi.disable") : t("shareUi.enable")}</Button><Button size="sm" variant="outline" className="text-red-500" onClick={() => remove(share)}><Trash2 size={13} /></Button></div>
<div className="mt-3 flex flex-wrap gap-1.5"><Button size="sm" variant="outline" onClick={() => copy(url, share.id)}>{copied === share.id ? <Check size={13} /> : <Copy size={13} />}<span className="ml-1">{t("shareUi.copy")}</span></Button>{share.hasPassword && <Button size="sm" variant="outline" onClick={() => void copyWithPassword(share)} title={copySessionRef.current.get(share) ? t("shareUi.copyWithPasswordHint") : t("shareUi.passwordMustReset")}><Copy size={13} /><span className="ml-1">{copySessionRef.current.get(share) ? t("shareUi.copyWithPassword") : t("shareUi.resetPasswordToCopy")}</span></Button>}<Button size="sm" variant="outline" onClick={() => window.open(url, "_blank", "noopener,noreferrer")}><ExternalLink size={13} /></Button><Button size="sm" variant="outline" onClick={() => editShare(share)}><Pencil size={13} className="mr-1" />{t("shareUi.edit")}</Button><Button size="sm" variant="outline" onClick={() => resetViews(share)}><RotateCcw size={13} className="mr-1" />{t("shareUi.reset")}</Button><Button size="sm" variant="outline" onClick={() => rotate(share)}><RefreshCw size={13} className="mr-1" />{t("shareUi.replaceLink")}</Button><Button size="sm" variant="outline" onClick={() => mutate(() => api.updateShare(share.id, { isActive: active ? 0 : 1 }), active ? t("shareUi.disabledMessage") : t("shareUi.enabledMessage"))}>{active ? t("shareUi.disable") : t("shareUi.enable")}</Button><Button size="sm" variant="outline" className="text-red-500" onClick={() => remove(share)}><Trash2 size={13} /></Button></div>
</article>;
})}</div>}
</section>
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/i18n/coverageTranslations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,14 @@ export const zhCNCoverageTranslations = {
"copyFailed": "复制失败,请手动复制",
"copiedVerify": "链接已复制,请在无痕窗口、微信或未登录设备中验证",
"copied": "分享链接已复制",
"copyWithPassword": "复制链接和密码",
"copyWithPasswordHint": "链接与密码将一起复制到剪贴板,请只发送给可信任的人",
"resetPasswordToCopy": "重设密码后复制",
"passwordMustReset": "无法找回原密码,请先在左侧重新设置访问密码并保存",
"copiedWithPassword": "已复制分享链接和访问密码,请注意安全发送",
"copyNoteLabel": "笔记:",
"copyLinkLabel": "分享链接:",
"copyPasswordLabel": "访问密码:",
"operationFailed": "操作失败",
"rotateTitle": "轮换分享链接?",
"rotateWarning": "旧链接和旧密码访问令牌会立即失效,访问会话数会重置。",
Expand Down Expand Up @@ -974,6 +982,14 @@ export const enCoverageTranslations = {
"copyFailed": "Copy failed. Please copy manually",
"copiedVerify": "Link copied. Verify it in a private window, WeChat or on a signed-out device.",
"copied": "Share link copied",
"copyWithPassword": "Copy link and password",
"copyWithPasswordHint": "The link and password will be copied together. Share only with trusted recipients.",
"resetPasswordToCopy": "Reset password to copy",
"passwordMustReset": "The original password cannot be recovered. Set a new password on the left and save.",
"copiedWithPassword": "Copied link and password. Share securely.",
"copyNoteLabel": "Note: ",
"copyLinkLabel": "Share link: ",
"copyPasswordLabel": "Access password: ",
"operationFailed": "Operation failed",
"rotateTitle": "Regenerate share link?",
"rotateWarning": "The old link and password access tokens will immediately stop working, and access sessions will reset.",
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/lib/__tests__/shareCopySession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { ShareCopySession, formatShareWithPassword } from "@/lib/shareCopySession";

const protectedShare = {
id: "share-a", shareToken: "token-a", hasPassword: true, credentialVersion: 1,
};
describe("share copy with access password (session only)", () => {
it("copies a stable human-readable text and trims only the submitted credential", () => {
const memory = new ShareCopySession();
memory.remember(protectedShare, " abc123 ");
expect(memory.get(protectedShare)).toBe("abc123");
expect(formatShareWithPassword("第一行\n第二行", "https://example.com/share/token-a",
memory.get(protectedShare)!, { note:"笔记:",link:"分享链接:",password:"访问密码:" }))
.toBe("笔记:第一行 第二行\n分享链接:https://example.com/share/token-a\n访问密码:abc123");
});
it("never retrieves an existing share password without a successful explicit input", () => {
const memory = new ShareCopySession();
expect(memory.get(protectedShare)).toBeNull();
memory.remember(protectedShare, "");
expect(memory.get(protectedShare)).toBeNull();
expect(memory.get({ ...protectedShare, id:"other-share" })).toBeNull();
});
it("invalidates when token or server credential version changes", () => {
const memory = new ShareCopySession();
memory.remember(protectedShare, "abc123");
expect(memory.get({ ...protectedShare, credentialVersion:2 })).toBeNull();
expect(memory.get(protectedShare)).toBeNull();
memory.remember(protectedShare, "abc123");
expect(memory.get({ ...protectedShare, shareToken:"token-rotated" })).toBeNull();
memory.remember(protectedShare, "abc123");
expect(memory.get({ ...protectedShare, hasPassword:false })).toBeNull();
});
it("clears all secrets on dialog close and individual secrets on revoke", () => {
const memory = new ShareCopySession();
const other={ ...protectedShare, id:"share-b" };
memory.remember(protectedShare,"abc123");
memory.remember(other,"def456");
memory.forget(protectedShare.id);
expect(memory.get(protectedShare)).toBeNull();
expect(memory.get(other)).toBe("def456");
memory.clear();
expect(memory.get(other)).toBeNull();
});
it("does not remember unverified or passwordless server responses", () => {
const memory = new ShareCopySession();
memory.remember({ ...protectedShare, credentialVersion:undefined }, "abc123");
expect(memory.get(protectedShare)).toBeNull();
memory.remember({ ...protectedShare, hasPassword:false }, "abc123");
expect(memory.get(protectedShare)).toBeNull();
});
});
Loading
Loading