fix(files): the token list is fresh after the sessions end, a copy again reads Copied again, the Copied button says so

This commit is contained in:
2026-10-15 05:47:00 +02:00
parent 620f84dba4
commit a248ca6c13
10 changed files with 133 additions and 6 deletions
@@ -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();
});
+5 -2
View File
@@ -489,8 +489,11 @@ function CopyField({
size="sm"
autoFocus={autoFocus}
icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
// 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")}
@@ -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<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/api")>()),
api: h,
}));
vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
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(
<QueryClientProvider client={qc}>
<SignInMethods me={me} />
</QueryClientProvider>,
);
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));
});
});
+6 -2
View File
@@ -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",
@@ -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);
@@ -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());
+6 -2
View File
@@ -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<void> } {
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<number | undefined>(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") });
@@ -285,6 +285,7 @@
"copy": "Copy",
"copied": "Copied",
"copyField": "Copy: {{field}}",
"copiedField": "Copied: {{field}}",
"done": "Done"
}
}
@@ -285,6 +285,7 @@
"copy": "Másolás",
"copied": "Másolva",
"copyField": "Másolás: {{field}}",
"copiedField": "Másolva: {{field}}",
"done": "Kész"
}
}
@@ -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();