diff --git a/frontend/src/components/ApiTokens.dom.test.tsx b/frontend/src/components/ApiTokens.dom.test.tsx
index 984f6763..566fac04 100644
--- a/frontend/src/components/ApiTokens.dom.test.tsx
+++ b/frontend/src/components/ApiTokens.dom.test.tsx
@@ -20,6 +20,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
}),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
+vi.mock("../lib/clipboard", () => ({ copyText: vi.fn(async () => true) }));
import ApiTokens from "./ApiTokens";
@@ -192,6 +193,11 @@ describe("API tokens · making one", () => {
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.token)" }),
).toBe(document.activeElement);
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.tryIt)" });
+ // Once copied, its name says what it shows, so a voice command saying "Copied" finds it.
+ fireEvent.click(document.activeElement!);
+ await screen.findByRole("button", {
+ name: "settings.tokens.copiedField(settings.tokens.token)",
+ });
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" }));
expect(screen.queryByRole("dialog")).toBeNull();
});
diff --git a/frontend/src/components/ApiTokens.tsx b/frontend/src/components/ApiTokens.tsx
index 528567ff..628a4344 100644
--- a/frontend/src/components/ApiTokens.tsx
+++ b/frontend/src/components/ApiTokens.tsx
@@ -489,8 +489,11 @@ function CopyField({
size="sm"
autoFocus={autoFocus}
icon={copied ? : }
- // Two of these sit in one dialog: each is named by the value it copies.
- aria-label={t("settings.tokens.copyField", { field: label })}
+ // Two of these sit in one dialog: each is named by the value it copies, and by the word
+ // it shows, so a voice command saying what is on screen finds it.
+ aria-label={t(copied ? "settings.tokens.copiedField" : "settings.tokens.copyField", {
+ field: label,
+ })}
onClick={() => void copy(value)}
>
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
diff --git a/frontend/src/components/SettingsPanel.signIn.dom.test.tsx b/frontend/src/components/SettingsPanel.signIn.dom.test.tsx
new file mode 100644
index 00000000..fa9fc99f
--- /dev/null
+++ b/frontend/src/components/SettingsPanel.signIn.dom.test.tsx
@@ -0,0 +1,74 @@
+// @vitest-environment jsdom
+//
+// Ending every other session, or changing the password, revokes every API token on the server — so
+// the Tokens tab must not then show them from its cache as if they still worked.
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import type { Me } from "../lib/api";
+import { qk } from "../lib/queryKeys";
+
+const h = vi.hoisted(() => ({
+ logoutOthers: vi.fn(async () => ({ ok: true })),
+ setPassword: vi.fn(async (_p: string, _c?: string) => ({ ok: true })),
+}));
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
+ };
+});
+vi.mock("../lib/api", async (importOriginal) => ({
+ ...(await importOriginal()),
+ api: h,
+}));
+vi.mock("../lib/notifications", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, notify: vi.fn() };
+});
+vi.mock("./confirmContext", () => ({ useConfirm: () => vi.fn(async () => true) }));
+
+import { SignInMethods } from "./SettingsPanel";
+
+afterEach(cleanup);
+
+const me = { email: "a@example.com", has_password: true, google_enabled: false } as Me;
+
+function renderIt() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ qc.setQueryData(qk.apiTokens(), [{ id: 1 }]);
+ render(
+
+
+ ,
+ );
+ return qc;
+}
+
+const tokensStale = (qc: QueryClient) => qc.getQueryState(qk.apiTokens())?.isInvalidated;
+
+describe("SignInMethods", () => {
+ it("signing the other sessions out marks the token list stale", async () => {
+ const qc = renderIt();
+ fireEvent.click(screen.getByText("settings.account.sessions.action"));
+ await waitFor(() => expect(h.logoutOthers).toHaveBeenCalled());
+ await waitFor(() => expect(tokensStale(qc)).toBe(true));
+ });
+
+ it("a password changed marks the token list stale", async () => {
+ const qc = renderIt();
+ fireEvent.click(screen.getByText("settings.account.password.change"));
+ fireEvent.change(screen.getByPlaceholderText("settings.account.password.current"), {
+ target: { value: "old one" },
+ });
+ fireEvent.change(screen.getByPlaceholderText("settings.account.password.new"), {
+ target: { value: "a brand new one" },
+ });
+ fireEvent.click(screen.getByText("settings.account.password.save"));
+ await waitFor(() => expect(h.setPassword).toHaveBeenCalled());
+ await waitFor(() => expect(tokensStale(qc)).toBe(true));
+ });
+});
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index ec72bbc7..a67c1b0e 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -361,7 +361,7 @@ function AccessRow({
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
// (auth.py attaches the identity to the current session via /auth/link); the password form posts
// directly with inline errors.
-function SignInMethods({ me }: { me: Me }) {
+export function SignInMethods({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
@@ -387,6 +387,8 @@ function SignInMethods({ me }: { me: Me }) {
setSessBusy(true);
try {
await api.logoutOthers();
+ // Every API token went with the other sessions: the Tokens tab must not show them from cache.
+ void qc.invalidateQueries({ queryKey: qk.apiTokens() });
notify({ level: "success", message: t("settings.account.sessions.done") });
} catch (e) {
notifyActionError(e, t("settings.account.sessions.failed"));
@@ -404,7 +406,9 @@ function SignInMethods({ me }: { me: Me }) {
setOpen(false);
setCurrent("");
setNext("");
- // Refresh `me` so has_password flips and the section switches to "Change password".
+ // Refresh `me` so has_password flips and the section switches to "Change password". Every API
+ // token went with the old password, so the Tokens tab must not show them from cache either.
+ void qc.invalidateQueries({ queryKey: qk.apiTokens() });
await qc.invalidateQueries({ queryKey: qk.me() });
notify({
level: "success",
diff --git a/frontend/src/components/UploadDialog.dom.test.tsx b/frontend/src/components/UploadDialog.dom.test.tsx
index 61b94203..ef1762fa 100644
--- a/frontend/src/components/UploadDialog.dom.test.tsx
+++ b/frontend/src/components/UploadDialog.dom.test.tsx
@@ -122,6 +122,20 @@ describe("UploadDialog", () => {
expect(h.uploadEnded).toHaveBeenCalledWith(null);
});
+ it("an upload that failed or was cut off still says it ended, so later finishes are announced", async () => {
+ h.uploadFile.mockRejectedValueOnce(new Error("network"));
+ h.uploadStarted.mockClear();
+ h.uploadEnded.mockClear();
+ wrap();
+ fireEvent.change(fileInput(), {
+ target: { files: [new File(["x"], "clip.mp4", { type: "video/mp4" })] },
+ });
+ fireEvent.click(screen.getByText("downloads.upload.submit"));
+ await waitFor(() => expect(h.uploadEnded).toHaveBeenCalledTimes(1));
+ expect(h.uploadEnded).toHaveBeenCalledWith(null);
+ expect(h.uploadStarted).toHaveBeenCalledTimes(1);
+ });
+
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
// Done: Files opens on the file itself, in its folder. Still converting: the tray opens on it.
h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never);
diff --git a/frontend/src/components/useCopyFeedback.dom.test.ts b/frontend/src/components/useCopyFeedback.dom.test.ts
index 62bc475b..c2be1ee7 100644
--- a/frontend/src/components/useCopyFeedback.dom.test.ts
+++ b/frontend/src/components/useCopyFeedback.dom.test.ts
@@ -32,6 +32,18 @@ describe("useCopyFeedback", () => {
expect(result.current.copied).toBe(false);
});
+ it("a copy again within the moment reads Copied for a whole moment again", async () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(() => useCopyFeedback());
+ await act(() => result.current.copy("sfl_x"));
+ act(() => void vi.advanceTimersByTime(1500));
+ await act(() => result.current.copy("sfl_x"));
+ act(() => void vi.advanceTimersByTime(1000)); // past the first copy's moment
+ expect(result.current.copied).toBe(true);
+ act(() => void vi.advanceTimersByTime(1000));
+ expect(result.current.copied).toBe(false);
+ });
+
it("a copy the clipboard refused is a warning, and nothing reads Copied", async () => {
h.copyText.mockResolvedValueOnce(false);
const { result } = renderHook(() => useCopyFeedback());
diff --git a/frontend/src/components/useCopyFeedback.ts b/frontend/src/components/useCopyFeedback.ts
index 161ca6ce..6e05f60e 100644
--- a/frontend/src/components/useCopyFeedback.ts
+++ b/frontend/src/components/useCopyFeedback.ts
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../lib/clipboard";
@@ -17,11 +17,15 @@ const COPIED_MS = 2000;
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise } {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
+ // One reset at a time: a copy again within the moment starts it over, not cut short by the last.
+ const timer = useRef(undefined);
+ useEffect(() => () => window.clearTimeout(timer.current), []);
const copy = async (value: string) => {
if (await copyText(value)) {
setCopied(true);
notify({ level: "success", message: t("common.copy.done") });
- window.setTimeout(() => setCopied(false), COPIED_MS);
+ window.clearTimeout(timer.current);
+ timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
return;
}
notify({ level: "warning", message: t("common.copy.failed") });
diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json
index 8aee5fda..0475ae82 100644
--- a/frontend/src/i18n/locales/en/settings.json
+++ b/frontend/src/i18n/locales/en/settings.json
@@ -285,6 +285,7 @@
"copy": "Copy",
"copied": "Copied",
"copyField": "Copy: {{field}}",
+ "copiedField": "Copied: {{field}}",
"done": "Done"
}
}
diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json
index d9623852..767c6174 100644
--- a/frontend/src/i18n/locales/hu/settings.json
+++ b/frontend/src/i18n/locales/hu/settings.json
@@ -285,6 +285,7 @@
"copy": "Másolás",
"copied": "Másolva",
"copyField": "Másolás: {{field}}",
+ "copiedField": "Másolva: {{field}}",
"done": "Kész"
}
}
diff --git a/frontend/src/lib/downloadQueue.dom.test.ts b/frontend/src/lib/downloadQueue.dom.test.ts
index 00d8db38..ea491c73 100644
--- a/frontend/src/lib/downloadQueue.dom.test.ts
+++ b/frontend/src/lib/downloadQueue.dom.test.ts
@@ -72,6 +72,14 @@ describe("downloadQueue · a finish", () => {
expect(takeUnannounced().map((j) => j.id)).toEqual([9]);
});
+ it("a download finishing during an upload is announced at once, not held for the upload", () => {
+ noteQueue([]);
+ uploadStarted();
+ noteQueue([job(8, "done")]);
+ expect(takeUnannounced().map((j) => j.id)).toEqual([8]);
+ uploadEnded(null);
+ });
+
it("an upload that failed announced nothing: what landed meanwhile is announced", () => {
noteQueue([]);
uploadStarted();