diff --git a/.env.local.sample b/.env.local.sample index 402d275d..0472b90b 100644 --- a/.env.local.sample +++ b/.env.local.sample @@ -4,3 +4,7 @@ VITE_BACKEND_API_VERSION=api/v1/ VITE_DEBUG_MODE=true VITE_DEV_LOGGER_LEVEL=3 + +# Optional OSC login URL. When set, the app redirects here on a 401 from the +# manager (expired token / not logged in) so the user can re-authenticate. +# AUTH=https://app.osaas.io/?redirect=/dashboard/service/eyevinn-intercom-manager diff --git a/README.md b/README.md index 9af2dc71..954387c1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Decide whether or not debug mode should be on or not `VITE_DEBUG_MODE=true` Choose desired level of logging `VITE_DEV_LOGGER_LEVEL=3` +Optionally set an OSC login URL with `AUTH=https://app.osaas.io/?redirect=/dashboard/service/eyevinn-intercom-manager`. When this build-time env var is set, the app redirects the browser to it whenever the manager returns a 401 (expired token or not logged in), letting the user (re)authenticate. When unset, behavior is unchanged. + ``` LOGGER LEVELS 0 = no logs diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 257223ef..621012b2 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -10,6 +10,6 @@ fi echo "VITE_BACKEND_URL=$API_URL" -VITE_BACKEND_URL=$API_URL npm run build && \ +VITE_BACKEND_URL=$API_URL AUTH=$AUTH npm run build && \ cp -r /app/dist/* /usr/share/nginx/html/ && \ nginx -g 'daemon off;' diff --git a/src/api/handle-fetch-request.ts b/src/api/handle-fetch-request.ts index 8e7da8b0..45ab9726 100644 --- a/src/api/handle-fetch-request.ts +++ b/src/api/handle-fetch-request.ts @@ -1,3 +1,5 @@ +import { maybeRedirectToAuth } from "./redirect-on-auth-failure.ts"; + const isSuccessful = (r: Response) => r.status >= 200 && r.status <= 399; export const handleFetchRequest = async ( @@ -19,6 +21,9 @@ export const handleFetchRequest = async ( if (!isSuccess) { const { status } = response; + // When built with the `AUTH` env var, redirect to the OSC login URL on a + // 401 before throwing. No-op (and existing reauth flow runs) when unset. + maybeRedirectToAuth(status); let err: Error; if (text) { err = new Error(text); diff --git a/src/api/redirect-on-auth-failure.test.ts b/src/api/redirect-on-auth-failure.test.ts new file mode 100644 index 00000000..3bff15a0 --- /dev/null +++ b/src/api/redirect-on-auth-failure.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { maybeRedirectToAuth } from "./redirect-on-auth-failure.ts"; + +const AUTH_URL = + "https://app.osaas.io/?redirect=/dashboard/service/eyevinn-intercom-manager"; + +describe("maybeRedirectToAuth", () => { + let assignSpy: ReturnType; + + beforeEach(() => { + // happy-dom would perform a real navigation on assign; stub it out so we can + // assert the call without side effects. + assignSpy = vi + .spyOn(window.location, "assign") + .mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("redirects to AUTH on a 401 when AUTH is set", () => { + vi.stubEnv("AUTH", AUTH_URL); + + const result = maybeRedirectToAuth(401); + + expect(result).toBe(true); + expect(assignSpy).toHaveBeenCalledTimes(1); + expect(assignSpy).toHaveBeenCalledWith(AUTH_URL); + }); + + it("does nothing on a 401 when AUTH is unset", () => { + vi.stubEnv("AUTH", ""); + + const result = maybeRedirectToAuth(401); + + expect(result).toBe(false); + expect(assignSpy).not.toHaveBeenCalled(); + }); + + it("does nothing on a non-401 status even when AUTH is set", () => { + vi.stubEnv("AUTH", AUTH_URL); + + expect(maybeRedirectToAuth(500)).toBe(false); + expect(maybeRedirectToAuth(200)).toBe(false); + expect(maybeRedirectToAuth(403)).toBe(false); + expect(assignSpy).not.toHaveBeenCalled(); + }); + + it("does not redirect if already at the AUTH url (loop guard)", () => { + vi.stubEnv("AUTH", window.location.href); + + const result = maybeRedirectToAuth(401); + + expect(result).toBe(false); + expect(assignSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/redirect-on-auth-failure.ts b/src/api/redirect-on-auth-failure.ts new file mode 100644 index 00000000..aff33b9b --- /dev/null +++ b/src/api/redirect-on-auth-failure.ts @@ -0,0 +1,22 @@ +/** + * When the app is built with the `AUTH` env var set to an OSC login URL and the + * manager returns HTTP 401 (token expired / not logged in), navigate the browser + * to that login URL so the user can (re)authenticate. + * + * When `AUTH` is unset the function is a no-op and returns false, leaving the + * existing OSC reauth flow unchanged. + * + * @returns true if a redirect was triggered, otherwise false. + */ +export const maybeRedirectToAuth = (status: number): boolean => { + if (status !== 401) return false; + + const authUrl = import.meta.env.AUTH; + if (typeof authUrl !== "string" || authUrl.length === 0) return false; + + // Guard against a redirect loop if we are already at the AUTH url. + if (window.location.href === authUrl) return false; + + window.location.assign(authUrl); + return true; +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index b1f45c78..b05a7dbf 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,2 +1,15 @@ /// /// + +interface ImportMetaEnv { + /** + * Optional OSC login URL. When set at build time (e.g. + * `AUTH=https://app.osaas.io/?redirect=/dashboard/service/eyevinn-intercom-manager`), + * the app redirects here whenever the manager returns HTTP 401. + */ + readonly AUTH?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.ts b/vite.config.ts index 3c438ca9..29d13cac 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,9 @@ import svgr from "vite-plugin-svgr"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react(), svgr()], + // Expose the literally-named `AUTH` build-time env var (no VITE_ prefix) to + // `import.meta.env` in addition to the default `VITE_`-prefixed vars. + envPrefix: ["VITE_", "AUTH"], test: { globals: true, environment: "happy-dom",