From 4f7ea76347e7ce19f369023cab39a09984f043b6 Mon Sep 17 00:00:00 2001 From: Alexlangl <49755039+Alexlangl@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:13:21 +0800 Subject: [PATCH] fix: preserve WebDAV password display and validate MKCOL 405 (#1685) * fix: preserve WebDAV password display and validate MKCOL 405 * fix: scope WebDAV password preservation to post-save refresh --- src-tauri/src/services/webdav.rs | 7 +- src/components/settings/WebdavSyncSection.tsx | 69 +++++++++-- tests/components/WebdavSyncSection.test.tsx | 110 +++++++++++++++++- 3 files changed, 171 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/services/webdav.rs b/src-tauri/src/services/webdav.rs index f0d0717e7..af2ff26c6 100644 --- a/src-tauri/src/services/webdav.rs +++ b/src-tauri/src/services/webdav.rs @@ -204,10 +204,11 @@ pub async fn ensure_remote_directories( s if s == StatusCode::CREATED || s.is_success() => { log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url)); } - // 405 commonly means "already exists" on many WebDAV servers - StatusCode::METHOD_NOT_ALLOWED => {} // Ambiguous — verify directory actually exists via PROPFIND - s if s == StatusCode::CONFLICT || s.is_redirection() => { + s if s == StatusCode::METHOD_NOT_ALLOWED + || s == StatusCode::CONFLICT + || s.is_redirection() => + { if !propfind_exists(&client, &dir_url, auth).await? { return Err(webdav_status_error("MKCOL", status, &dir_url)); } diff --git a/src/components/settings/WebdavSyncSection.tsx b/src/components/settings/WebdavSyncSection.tsx index 0161a9a73..0c072b737 100644 --- a/src/components/settings/WebdavSyncSection.tsx +++ b/src/components/settings/WebdavSyncSection.tsx @@ -98,6 +98,20 @@ function formatDbCompatVersion(version?: number | null): string | null { return typeof version === "number" ? `db-v${version}` : null; } +function buildPasswordPreservationKey(values: { + baseUrl?: string | null; + username?: string | null; + remoteRoot?: string | null; + profile?: string | null; +}) { + return JSON.stringify({ + baseUrl: values.baseUrl ?? "", + username: values.username ?? "", + remoteRoot: values.remoteRoot ?? "cc-switch-sync", + profile: values.profile ?? "default", + }); +} + // ─── Types ────────────────────────────────────────────────── type ActionState = @@ -167,6 +181,10 @@ export function WebdavSyncSection({ const [passwordTouched, setPasswordTouched] = useState(false); const [justSaved, setJustSaved] = useState(false); const justSavedTimerRef = useRef | null>(null); + const pendingPasswordPreservationRef = useRef<{ + key: string; + password: string; + } | null>(null); // Local form state — credentials are only persisted on explicit "Save". const [form, setForm] = useState(() => ({ @@ -205,13 +223,36 @@ export function WebdavSyncSection({ // Sync form when config is loaded/updated from backend, but not while user is editing useEffect(() => { if (!config || dirty) return; - setForm({ - baseUrl: config.baseUrl ?? "", - username: config.username ?? "", - password: config.password ?? "", - remoteRoot: config.remoteRoot ?? "cc-switch-sync", - profile: config.profile ?? "default", - autoSync: config.autoSync ?? false, + setForm(() => { + const nextBaseUrl = config.baseUrl ?? ""; + const nextUsername = config.username ?? ""; + const nextRemoteRoot = config.remoteRoot ?? "cc-switch-sync"; + const nextProfile = config.profile ?? "default"; + const nextKey = buildPasswordPreservationKey({ + baseUrl: nextBaseUrl, + username: nextUsername, + remoteRoot: nextRemoteRoot, + profile: nextProfile, + }); + const shouldPreserveRedactedPassword = + !config.password && + pendingPasswordPreservationRef.current?.key === nextKey && + !!pendingPasswordPreservationRef.current.password; + + const nextPassword = shouldPreserveRedactedPassword + ? pendingPasswordPreservationRef.current!.password + : (config.password ?? ""); + + pendingPasswordPreservationRef.current = null; + + return { + baseUrl: nextBaseUrl, + username: nextUsername, + password: nextPassword, + remoteRoot: nextRemoteRoot, + profile: nextProfile, + autoSync: config.autoSync ?? false, + }; }); setPasswordTouched(false); setPresetId(detectPreset(config.baseUrl ?? "")); @@ -289,12 +330,13 @@ export function WebdavSyncSection({ enabled: true, baseUrl, username: form.username.trim(), - password: form.password, + // 未重新触碰密码时,提交空值让后端沿用已保存密码,表单里的值仅用于 UI 显示 + password: passwordTouched ? form.password : "", remoteRoot: form.remoteRoot.trim() || "cc-switch-sync", profile: form.profile.trim() || "default", autoSync: form.autoSync, }; - }, [form]); + }, [form, passwordTouched]); // ─── Handlers ─────────────────────────────────────────── @@ -326,6 +368,12 @@ export function WebdavSyncSection({ return; } setActionState("saving"); + pendingPasswordPreservationRef.current = form.password + ? { + key: buildPasswordPreservationKey(settings), + password: form.password, + } + : null; try { await settingsApi.webdavSyncSaveSettings(settings, passwordTouched); setDirty(false); @@ -339,6 +387,7 @@ export function WebdavSyncSection({ }, 2000); await queryClient.invalidateQueries(); } catch (error) { + pendingPasswordPreservationRef.current = null; toast.error( t("settings.webdavSync.saveFailed", { error: (error as Error)?.message ?? String(error), @@ -362,7 +411,7 @@ export function WebdavSyncSection({ } finally { setActionState("idle"); } - }, [buildSettings, passwordTouched, queryClient, t]); + }, [buildSettings, form.password, passwordTouched, queryClient, t]); /** Fetch remote info, then open upload confirmation dialog. */ const handleUploadClick = useCallback(async () => { diff --git a/tests/components/WebdavSyncSection.test.tsx b/tests/components/WebdavSyncSection.test.tsx index 72e647bab..b6adc06f8 100644 --- a/tests/components/WebdavSyncSection.test.tsx +++ b/tests/components/WebdavSyncSection.test.tsx @@ -104,11 +104,12 @@ function renderSection(config?: WebDavSyncSettings) { mutations: { retry: false }, }, }); - return render( + const view = render( , ); + return { ...view, client }; } describe("WebdavSyncSection", () => { @@ -204,7 +205,7 @@ describe("WebdavSyncSection", () => { expect.objectContaining({ baseUrl: "https://dav.example.com/dav/", username: "alice", - password: "secret", + password: "", autoSync: false, }), false, @@ -222,6 +223,111 @@ describe("WebdavSyncSection", () => { ); }); + it("preserves password only for the single post-save refresh", async () => { + const view = renderSection(baseConfig); + + fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" })); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1); + }); + + view.rerender( + + + , + ); + + expect( + ( + screen.getByPlaceholderText( + "settings.webdavSync.passwordPlaceholder", + ) as HTMLInputElement + ).value, + ).toBe("secret"); + + view.rerender( + + + , + ); + + expect( + ( + screen.getByPlaceholderText( + "settings.webdavSync.passwordPlaceholder", + ) as HTMLInputElement + ).value, + ).toBe(""); + }); + + it("does not preserve password after a later external config refresh", async () => { + const view = renderSection(baseConfig); + + fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" })); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1); + }); + + view.rerender( + + + , + ); + + expect( + ( + screen.getByPlaceholderText( + "settings.webdavSync.passwordPlaceholder", + ) as HTMLInputElement + ).value, + ).toBe("secret"); + + view.rerender( + + + , + ); + + expect( + ( + screen.getByPlaceholderText( + "settings.webdavSync.passwordPlaceholder", + ) as HTMLInputElement + ).value, + ).toBe(""); + }); + + it("does not submit a preserved password again when testing without touching it", async () => { + const view = renderSection(baseConfig); + + fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.save" })); + + await waitFor(() => { + expect(settingsApiMock.webdavSyncSaveSettings).toHaveBeenCalledTimes(1); + }); + + view.rerender( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "settings.webdavSync.test" })); + + await waitFor(() => { + expect(settingsApiMock.webdavTestConnection).toHaveBeenLastCalledWith( + expect.objectContaining({ + password: "", + }), + true, + ); + }); + }); + it("saves auto sync as true after toggle", async () => { renderSection(baseConfig);