mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-06 22:01:44 +08:00
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
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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 () => {
|
||||
|
||||
@@ -104,11 +104,12 @@ function renderSection(config?: WebDavSyncSettings) {
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WebdavSyncSection config={config} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"settings.webdavSync.passwordPlaceholder",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("secret");
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"settings.webdavSync.passwordPlaceholder",
|
||||
) as HTMLInputElement
|
||||
).value,
|
||||
).toBe("secret");
|
||||
|
||||
view.rerender(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection
|
||||
config={{ ...baseConfig, username: "bob", password: "" }}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={view.client}>
|
||||
<WebdavSyncSection config={{ ...baseConfig, password: "" }} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user