From a248ca6c13f9781dacd5317b87787e13a0d94ecc Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 15 Oct 2026 05:47:00 +0200 Subject: [PATCH] fix(files): the token list is fresh after the sessions end, a copy again reads Copied again, the Copied button says so --- .../src/components/ApiTokens.dom.test.tsx | 6 ++ frontend/src/components/ApiTokens.tsx | 7 +- .../SettingsPanel.signIn.dom.test.tsx | 74 +++++++++++++++++++ frontend/src/components/SettingsPanel.tsx | 8 +- .../src/components/UploadDialog.dom.test.tsx | 14 ++++ .../components/useCopyFeedback.dom.test.ts | 12 +++ frontend/src/components/useCopyFeedback.ts | 8 +- frontend/src/i18n/locales/en/settings.json | 1 + frontend/src/i18n/locales/hu/settings.json | 1 + frontend/src/lib/downloadQueue.dom.test.ts | 8 ++ 10 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/SettingsPanel.signIn.dom.test.tsx 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();