diff --git a/CHANGELOG.md b/CHANGELOG.md index 27b04f58..ea3de73d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ All notable changes to this project will be documented in this file. - Optimized list loading. - Removed fixture length check from test. - Added vitest for frontend unit tests. +- Added spinner when retrieving bind key. - Added BRND to feed source admin dropdown. - Upgraded to PHP 8.4. - Changed default CLIENT_PULL_STRATEGY_INTERVAL value to 10 minutes. diff --git a/assets/client/app.jsx b/assets/client/app.jsx index 779f62bd..cc9d6bde 100644 --- a/assets/client/app.jsx +++ b/assets/client/app.jsx @@ -28,6 +28,7 @@ function App({ preview, previewId }) { const [bindKey, setBindKey] = useState(null); const [displayFallback, setDisplayFallback] = useState(true); const [debug, setDebug] = useState(false); + const [retrievingBindKey, setRetrievingBindKey] = useState(true); const checkLoginTimeoutRef = useRef(null); const contentServiceRef = useRef(null); @@ -71,6 +72,7 @@ function App({ preview, previewId }) { setBindKey(null); setRunning(true); + setRetrievingBindKey(false); contentServiceRef.current = new ContentService(); @@ -119,17 +121,31 @@ function App({ preview, previewId }) { tokenService .checkLogin() .then((data) => { - if (data.status === constants.LOGIN_STATUS_READY) { - startContent(data.screenId); - } else if (data.status === constants.LOGIN_STATUS_AWAITING_BIND_KEY) { - if (data?.bindKey) { - setBindKey(data.bindKey); - } - - restartLoginTimeout(); + switch (data.status) { + case constants.LOGIN_STATUS_READY: + setRetrievingBindKey(false); + startContent(data.screenId); + break; + case constants.LOGIN_STATUS_AWAITING_BIND_KEY: + setRetrievingBindKey(false); + + if (data?.bindKey) { + setBindKey(data.bindKey); + } + + restartLoginTimeout(); + break; + case constants.LOGIN_STATUS_UNKNOWN: + default: + // retrievingBindKey intentionally not reset — restartLoginTimeout + // retries checkLogin, so we are still retrieving. + restartLoginTimeout(); + break; } }) .catch(() => { + // retrievingBindKey intentionally not reset — restartLoginTimeout + // retries checkLogin, so we are still retrieving. restartLoginTimeout(); }); } @@ -162,7 +178,9 @@ function App({ preview, previewId }) { } setScreen(null); + setBindKey(null); setRunning(false); + setRetrievingBindKey(true); tokenService.stopRefreshing(); @@ -285,6 +303,11 @@ function App({ preview, previewId }) { {displayFallback && !bindKey && (
)} + {!preview && retrievingBindKey && !bindKey && ( +
+
+
+ )}
); } diff --git a/assets/client/app.scss b/assets/client/app.scss index 2f68c5a8..3036e962 100644 --- a/assets/client/app.scss +++ b/assets/client/app.scss @@ -52,6 +52,27 @@ body { } } +.retrieving-bind-key-container { + position: absolute; + bottom: 10px; + right: 10px; + + .retrieving-bind-key-spinner { + width: 2rem; + height: 2rem; + border: 0.25em solid rgba(255, 255, 255, 0.25); + border-top-color: white; + border-radius: 50%; + animation: bind-key-spin 0.75s linear infinite; + } +} + +@keyframes bind-key-spin { + to { + transform: rotate(360deg); + } +} + .ql-editor { min-height: 200px; } diff --git a/assets/tests/client/app-bind-key.test.jsx b/assets/tests/client/app-bind-key.test.jsx new file mode 100644 index 00000000..654407c2 --- /dev/null +++ b/assets/tests/client/app-bind-key.test.jsx @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, act, screen, cleanup } from "@testing-library/react"; + +const { mockCheckLogin, mockRefreshToken } = vi.hoisted(() => ({ + mockCheckLogin: vi.fn(), + mockRefreshToken: vi.fn(), +})); + +vi.mock("../../client/logger/logger", () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../../client/util/app-storage", () => ({ + default: { + getToken: vi.fn(() => null), + getRefreshToken: vi.fn(() => null), + getScreenId: vi.fn(() => null), + getTokenExpire: vi.fn(() => null), + getTokenIssueAt: vi.fn(() => null), + getFallbackImageUrl: vi.fn(() => null), + setToken: vi.fn(), + setRefreshToken: vi.fn(), + setScreenId: vi.fn(), + setTenant: vi.fn(), + setPreviousBoot: vi.fn(), + clearToken: vi.fn(), + clearRefreshToken: vi.fn(), + clearScreenId: vi.fn(), + clearTenant: vi.fn(), + clearFallbackImageUrl: vi.fn(), + clearAppStorage: vi.fn(), + }, +})); + +vi.mock("../../client/service/token-service", () => ({ + default: { + checkLogin: mockCheckLogin, + refreshToken: mockRefreshToken, + startRefreshing: vi.fn(), + stopRefreshing: vi.fn(), + checkToken: vi.fn(), + }, +})); + +vi.mock("../../client/service/status-service", () => ({ + default: { + setStatus: vi.fn(), + setError: vi.fn(), + setStatusInUrl: vi.fn(), + error: null, + }, +})); + +vi.mock("../../client/service/release-service", () => ({ + default: { + checkForNewRelease: vi.fn(() => Promise.resolve()), + setPreviousBootInUrl: vi.fn(), + startReleaseCheck: vi.fn(), + stopReleaseCheck: vi.fn(), + setScreenIdInUrl: vi.fn(), + }, +})); + +vi.mock("../../client/service/tenant-service", () => ({ + default: { loadTenantConfig: vi.fn() }, +})); + +vi.mock("../../client/util/client-config-loader.js", () => ({ + default: { + loadConfig: vi.fn(() => Promise.resolve({ loginCheckTimeout: 50 })), + }, +})); + +vi.mock("../../client/service/content-service", () => { + return { + default: vi.fn().mockImplementation(function () { + this.start = vi.fn(); + this.stop = vi.fn(); + }), + }; +}); + +vi.mock("../../client/app.scss", () => ({})); +vi.mock("../../client/assets/fallback.png", () => ({ default: "" })); +vi.mock("../../client/components/screen.jsx", () => ({ + default: () =>
, +})); + +import App from "../../client/app.jsx"; + +/** + * Flush microtasks, advance timers, flush again. + * restartLoginTimeout creates setTimeout inside a .then() on loadConfig — + * microtasks must resolve first for the timer to exist. + */ +async function tick(ms) { + await act(async () => {}); + await act(async () => { + vi.advanceTimersByTime(ms); + }); + await act(async () => {}); +} + +describe("checkLogin retries on unknown status", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockCheckLogin.mockReset(); + mockRefreshToken.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("continues login polling when checkLogin returns unknown status", async () => { + // The initial checkLogin (on mount) returns unknown status. + // Without the fix, the polling loop stops dead — no restartLoginTimeout. + // With the fix, unknown triggers restartLoginTimeout and the second + // call returns awaitingBindKey, showing the bind key. + mockCheckLogin + .mockResolvedValueOnce({ status: "unknown" }) + .mockResolvedValueOnce({ status: "awaitingBindKey", bindKey: "BIND42" }) + .mockResolvedValue({ status: "awaitingBindKey", bindKey: "BIND42" }); + + const { container } = render(); + + // Mount: releaseService.checkForNewRelease resolves → checkLogin (call 1) + // → unknown status → with fix: restartLoginTimeout → 50ms timer. + await tick(0); + + // Bind key should NOT be visible yet (unknown status, no bind key set). + expect(screen.queryByText("BIND42")).not.toBeInTheDocument(); + // Spinner should still be visible — we are still retrieving. + expect( + container.querySelector(".retrieving-bind-key-spinner"), + ).toBeInTheDocument(); + + // Advance past loginCheckTimeout to fire the retry timer. + // Call 2 → awaitingBindKey "BIND42". + await tick(60); + + // With the fix, the login loop retried and the bind key is now shown. + expect(screen.getByText("BIND42")).toBeInTheDocument(); + // Spinner gone — bind key received. + expect( + container.querySelector(".retrieving-bind-key-spinner"), + ).not.toBeInTheDocument(); + }); +}); + +describe("reauthenticateHandler shows spinner while retrieving bind key", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockCheckLogin.mockReset(); + mockRefreshToken.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("recovers from reauth failure and shows new bind key", async () => { + // Call 1 (mount): normal login with bind key. + // Call 2 (reauth → checkLogin): returns new bind key after refresh + // token failure triggers full re-login. + mockCheckLogin + .mockResolvedValueOnce({ status: "awaitingBindKey", bindKey: "INIT01" }) + .mockResolvedValue({ status: "awaitingBindKey", bindKey: "REAUTH01" }); + + // refreshToken will fail when reauthenticate fires. + mockRefreshToken.mockRejectedValue(new Error("token expired")); + + const { container } = render(); + + // Let mount complete. + await tick(0); + expect(screen.getByText("INIT01")).toBeInTheDocument(); + // Spinner hidden — bind key is shown. + expect( + container.querySelector(".retrieving-bind-key-spinner"), + ).not.toBeInTheDocument(); + + // Fire reauthenticate event — refreshToken rejects → catch block + // clears screen/bindKey, sets retrievingBindKey, calls checkLogin + // which resolves with new bind key. + await act(async () => { + document.dispatchEvent(new Event("reauthenticate")); + }); + await tick(60); + + // Old bind key gone, new bind key visible — proves the full reauth + // recovery flow works: clear state → checkLogin → new bind key. + expect(screen.queryByText("INIT01")).not.toBeInTheDocument(); + expect(screen.getByText("REAUTH01")).toBeInTheDocument(); + // Spinner gone after recovery. + expect( + container.querySelector(".retrieving-bind-key-spinner"), + ).not.toBeInTheDocument(); + }); +}); + +describe("spinner persists during checkLogin retry on failure", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockCheckLogin.mockReset(); + mockRefreshToken.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("keeps spinner visible when checkLogin rejects", async () => { + // First call rejects (network error), retry succeeds with bind key. + mockCheckLogin + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ status: "awaitingBindKey", bindKey: "RETRY01" }) + .mockResolvedValue({ status: "awaitingBindKey", bindKey: "RETRY01" }); + + const { container } = render(); + + await tick(0); + + // checkLogin rejected → catch → restartLoginTimeout. + // retrievingBindKey was never reset, so spinner is still visible. + expect( + container.querySelector(".retrieving-bind-key-spinner"), + ).toBeInTheDocument(); + + // Advance past the retry timeout. + await tick(60); + + // Retry succeeded with bind key — spinner gone. + expect( + container.querySelector(".retrieving-bind-key-spinner"), + ).not.toBeInTheDocument(); + expect(screen.getByText("RETRY01")).toBeInTheDocument(); + }); +}); + +describe("spinner not shown in preview mode", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockCheckLogin.mockReset(); + mockRefreshToken.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it("does not show spinner when preview is set", async () => { + const { container } = render(); + + await tick(0); + + // Even though retrievingBindKey starts true, preview mode suppresses it. + expect( + container.querySelector(".retrieving-bind-key-spinner"), + ).not.toBeInTheDocument(); + }); +});