diff --git a/bun.lockb b/bun.lockb index 666475a2..99286e83 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/client/package.json b/packages/client/package.json index cea40f5d..f3d4f4cc 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -33,7 +33,6 @@ "rollup-plugin-minify-html-literals-v3": "1.3.3", "vite-bundle-visualizer": "1.2.1", "vite-tsconfig-paths": "4.2.0", - "vitest": "1.6.0", - "msw": "2.3.0" + "vitest": "1.6.0" } } diff --git a/packages/client/src/logout-warning.ts b/packages/client/src/logout-warning.ts deleted file mode 100644 index 18831737..00000000 --- a/packages/client/src/logout-warning.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { ClientTexts } from "decorator-shared/types"; -import { - transformSessionToAuth, - fetchRenew, - fetchSession, -} from "./helpers/auth"; - -import { addSecondsFromNow, getSecondsRemaining } from "./helpers/time"; - -type Auth = { - sessionExpireAtLocal: string; - tokenExpireAtLocal: string; -}; - -function getDOMElements() { - const logoutWarningDialogEl = document.getElementById( - "logout-warning", - ) as HTMLDialogElement | null; - const titleEl = logoutWarningDialogEl?.querySelector( - "#logout-warning-title", - ); - const bodyEl = logoutWarningDialogEl?.querySelector("#logout-warning-body"); - const confirmButtonEl = logoutWarningDialogEl?.querySelector( - "#logout-warning-confirm", - ); - const cancelButtonEl = logoutWarningDialogEl?.querySelector( - "#logout-warning-cancel", - ); - - return { - logoutWarningDialogEl, - titleEl, - bodyEl, - confirmButtonEl, - cancelButtonEl, - }; -} - -function updateLogoutWarningUI(type: "token" | "session", minutes?: number) { - const { titleEl, bodyEl, confirmButtonEl, cancelButtonEl } = - getDOMElements(); - - const texts: ClientTexts = window.__DECORATOR_DATA__.texts; - - if (!titleEl || !bodyEl || !confirmButtonEl || !cancelButtonEl || !texts) { - return; - } - - const title = - type === "token" - ? texts.token_warning_title - : texts.session_warning_title.replace( - "$1", - minutes?.toString() ?? "", - ); - const body = - type === "token" - ? texts.token_warning_body - : texts.session_warning_body; - const confirm = type === "token" ? texts.yes : texts.ok; - const cancel = texts.logout; - - titleEl.innerHTML = title; - bodyEl.innerHTML = body; - confirmButtonEl.innerHTML = confirm; - cancelButtonEl.innerHTML = cancel; - - confirmButtonEl.setAttribute("data-type", type); - cancelButtonEl.setAttribute("data-type", type); -} - -export async function initLogoutWarning() { - let timeoutHandler: NodeJS.Timeout; - let auth: Auth | null = null; - let silenceWarning: boolean = false; - - const secondsToWarnBeforeExpiration = 5 * 60; // Give user 5 minutes to react to the warning. - - const { logoutWarningDialogEl } = getDOMElements(); - - // Session and token fetching from central login service - // --------------------------------------------- - async function updateAuthFromSession() { - const result = await fetchSession(); - if (!result?.session || !result?.tokens) { - auth = null; - return; - } - - auth = transformSessionToAuth(result); - } - - async function renewSessionAndUpdateAuth() { - const result = await fetchRenew(); - if (!result?.session && !result?.tokens) { - return; - } - auth = transformSessionToAuth(result); - } - - function silenceSessionWarning() { - if (logoutWarningDialogEl) { - logoutWarningDialogEl.close(); - } - silenceWarning = true; - } - - // Debug functions for testing token and session expiry - // --------------------------------------------- - function fakeTokenExpiration(seconds: number) { - if (auth) { - auth.tokenExpireAtLocal = addSecondsFromNow(seconds); - } else { - console.error( - "No tokens found in auth object. Cannot fake token expiry.", - ); - } - } - - function fakeSessionExpiration(seconds: number) { - if (auth) { - auth.sessionExpireAtLocal = addSecondsFromNow(seconds); - } else { - console.error( - "No tokens found in auth object. Cannot fake session expiry.", - ); - } - } - - // Updaters for the actual modal UI - // --------------------------------------------- - function showDialog(type: "token" | "session", minutes?: number) { - updateLogoutWarningUI(type, minutes); - - if (!logoutWarningDialogEl?.open || !silenceWarning) { - logoutWarningDialogEl?.showModal(); - } - } - - function periodicalLocalSessionCheck() { - timeoutHandler = setTimeout(() => { - periodicalLocalSessionCheck(); - }, 1000); - - // User is not logged in, so do nothing and end the timeout - if (!auth || !logoutWarningDialogEl) { - clearTimeout(timeoutHandler); - return; - } - - const secondsToTokenExpiration = getSecondsRemaining( - auth.tokenExpireAtLocal, - ); - const secondsToSessionExpiration = getSecondsRemaining( - auth.sessionExpireAtLocal, - ); - - if (secondsToTokenExpiration < 0 || secondsToSessionExpiration < 0) { - window.location.href = `${window.__DECORATOR_DATA__.env.LOGOUT_URL}`; - } - - if (secondsToTokenExpiration < secondsToWarnBeforeExpiration) { - showDialog("token"); - return; - } - - if (secondsToSessionExpiration < secondsToWarnBeforeExpiration) { - const minutesToSessionExpiration = Math.ceil( - secondsToSessionExpiration / 60, - ); - showDialog("session", minutesToSessionExpiration); - return; - } - - // Neither token nor session is about to expire, so close the dialog - // if it's open. This could happen if the user has multiple tabs open - // and has refreshet the token in one of them. - if (logoutWarningDialogEl.open) { - logoutWarningDialogEl.close(); - } - } - - // Event handlers - // --------------------------------------------- - function onConfirm() { - const { confirmButtonEl } = getDOMElements(); - const type = confirmButtonEl?.getAttribute("data-type"); - if (type === "token") { - renewSessionAndUpdateAuth(); - } else { - silenceSessionWarning(); - } - } - - function onCancel() { - // Note that in both cases, hitting "Log out" is considered - // cancelling the session and redirecting the user. - window.location.href = `${window.__DECORATOR_DATA__.env.LOGOUT_URL}`; - } - - function onVisibilityChange() { - if (document.visibilityState === "visible") { - updateAuthFromSession(); - } - } - - // Setup - // --------------------------------------------- - function setupDebugFunctionality() { - window.loginDebug = { - expireToken: fakeTokenExpiration, - expireSession: fakeSessionExpiration, - }; - } - - function startEventListeners() { - const { confirmButtonEl, cancelButtonEl } = getDOMElements(); - window.addEventListener("visibilitychange", onVisibilityChange); - confirmButtonEl?.addEventListener("click", onConfirm); - cancelButtonEl?.addEventListener("click", onCancel); - } - - if (!logoutWarningDialogEl) { - return; - } - - startEventListeners(); - setupDebugFunctionality(); - await updateAuthFromSession(); - periodicalLocalSessionCheck(); -} diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index e0bec0dd..db9b31e5 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -8,9 +8,8 @@ import { initHistoryEvents } from "./events"; import { addFaroMetaData } from "./faro"; import { buildHtmlElement } from "./helpers/html-element-builder"; import { cdnUrl } from "./helpers/urls"; -import { initLogoutWarning } from "./logout-warning"; import "./main.css"; -import { env, param, updateDecoratorParams } from "./params"; +import { param, updateDecoratorParams } from "./params"; import { useLoadIfActiveSession } from "./screensharing"; import.meta.glob("./styles/*.css", { eager: true }); @@ -44,35 +43,10 @@ const init = () => { initAuth().then((auth) => { initAnalytics(auth); }); - - if (param("logoutWarning")) { - initLogoutWarning(); - } -}; - -const enableMocking = async () => { - if (process.env.NODE_ENV !== "development") { - return; - } - - if (window.location.origin !== env("APP_URL")) { - console.log( - "Skipping mock worker as current origin is not decorator origin", - ); - return; - } - - const { worker } = await import("./mocks"); - - return worker.start({ - onUnhandledRequest: "bypass", - }); }; -enableMocking().then(() => { - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); - } else { - init(); - } -}); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} diff --git a/packages/client/src/mocks.ts b/packages/client/src/mocks.ts deleted file mode 100644 index 3d422cba..00000000 --- a/packages/client/src/mocks.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { http, HttpResponse } from "msw"; -import { setupWorker } from "msw/browser"; -import { - nowISOString, - addOneHourFromNow, - getSecondsRemaining, - addSixHoursFromNow, -} from "./helpers/time"; - -export const worker = setupWorker( - http.get("http://localhost:8089/api/oauth2/session", () => - HttpResponse.json({ - session: { - created_at: nowISOString(), - ends_at: addSixHoursFromNow(), - timeout_at: addOneHourFromNow(), - ends_in_seconds: getSecondsRemaining(addSixHoursFromNow()), - active: true, - timeout_in_seconds: getSecondsRemaining(addOneHourFromNow()), - }, - tokens: { - expire_at: addOneHourFromNow(), - refreshed_at: nowISOString(), - expire_in_seconds: getSecondsRemaining(addOneHourFromNow()), - next_auto_refresh_in_seconds: -1, - refresh_cooldown: true, - refresh_cooldown_seconds: 31, - }, - }), - ), - http.get("http://localhost:8089/api/oauth2/session/refresh", () => - HttpResponse.json({ - session: { - created_at: nowISOString(), - ends_at: addSixHoursFromNow(), - timeout_at: addOneHourFromNow(), - ends_in_seconds: getSecondsRemaining(addSixHoursFromNow()), - active: true, - timeout_in_seconds: getSecondsRemaining(addOneHourFromNow()), - }, - tokens: { - expire_at: addOneHourFromNow(), - refreshed_at: nowISOString(), - expire_in_seconds: getSecondsRemaining(addOneHourFromNow()), - next_auto_refresh_in_seconds: -1, - refresh_cooldown: true, - refresh_cooldown_seconds: 31, - }, - }), - ), -); diff --git a/packages/client/src/views/logout-warning.ts b/packages/client/src/views/logout-warning.ts new file mode 100644 index 00000000..5660335c --- /dev/null +++ b/packages/client/src/views/logout-warning.ts @@ -0,0 +1,77 @@ +import { defineCustomElement } from "../custom-elements"; +import { + SessionData, + fetchRenew, + fetchSession, + transformSessionToAuth, +} from "../helpers/auth"; +import { addSecondsFromNow } from "../helpers/time"; +import { param } from "../params"; +import { SessionDialog } from "./session-dialog"; +import { TokenDialog } from "./token-dialog"; + +class LogoutWarning extends HTMLElement { + private tokenDialog!: TokenDialog; + private sessionDialog!: SessionDialog; + + private onVisibilityChange = async () => { + if (param("logoutWarning") && document.visibilityState === "visible") { + this.updateDialogs(await fetchSession()); + } + }; + + private updateDialogs = (sessionData: SessionData | null) => { + if (sessionData) { + const { sessionExpireAtLocal, tokenExpireAtLocal } = + transformSessionToAuth(sessionData); + this.sessionDialog.sessionExpireAtLocal = sessionExpireAtLocal; + this.tokenDialog.tokenExpireAtLocal = tokenExpireAtLocal; + } else { + this.sessionDialog.sessionExpireAtLocal = undefined; + this.tokenDialog.tokenExpireAtLocal = undefined; + } + }; + + private init = async () => { + this.updateDialogs(await fetchSession()); + + window.loginDebug = { + expireToken: (seconds: number) => { + this.tokenDialog.tokenExpireAtLocal = + addSecondsFromNow(seconds); + }, + expireSession: (seconds: number) => { + this.sessionDialog.sessionExpireAtLocal = + addSecondsFromNow(seconds); + }, + }; + }; + + private handleParamsUpdated = (event: CustomEvent) => { + if (event.detail.params.logoutWarning) { + this.init(); + } + }; + + connectedCallback() { + window.addEventListener("visibilitychange", this.onVisibilityChange); + window.addEventListener("paramsupdated", this.handleParamsUpdated); + if (param("logoutWarning")) { + this.init(); + } + + this.sessionDialog = this.querySelector("session-dialog")!; + this.tokenDialog = this.querySelector("token-dialog")!; + this.tokenDialog.addEventListener("renew", async () => + this.updateDialogs(await fetchRenew()), + ); + } + + disconnectedCallback() { + window.removeEventListener("visibilitychange", this.onVisibilityChange); + window.removeEventListener("paramsupdated", this.handleParamsUpdated); + window.loginDebug = undefined as any; + } +} + +defineCustomElement("logout-warning", LogoutWarning); diff --git a/packages/client/src/views/session-dialog.ts b/packages/client/src/views/session-dialog.ts new file mode 100644 index 00000000..85535e99 --- /dev/null +++ b/packages/client/src/views/session-dialog.ts @@ -0,0 +1,50 @@ +import { defineCustomElement } from "../custom-elements"; +import { getSecondsRemaining } from "../helpers/time"; +import { env } from "../params"; + +export class SessionDialog extends HTMLElement { + sessionExpireAtLocal?: string; + private interval?: number; + private silenceWarning = false; + + private get secondsRemaining() { + return this.sessionExpireAtLocal + ? getSecondsRemaining(this.sessionExpireAtLocal) + : Infinity; + } + + connectedCallback() { + const dialog = this.querySelector("dialog") as HTMLDialogElement; + const form = dialog.querySelector("form") as HTMLFormElement; + + form.addEventListener("submit", (event: SubmitEvent) => { + event.preventDefault(); + const action = new FormData(form, event.submitter).get("action"); + if (action === "renew") { + this.silenceWarning = true; + dialog.close(); + } else { + window.location.href = `${env("LOGOUT_URL")}`; + } + }); + + this.interval = window.setInterval(() => { + if (this.secondsRemaining < 0) { + window.location.href = `${env("LOGOUT_URL")}`; + } else if (!this.silenceWarning && this.secondsRemaining < 5 * 60) { + dialog.querySelector(".session-time-remaining")!.innerHTML = + Math.ceil(this.secondsRemaining / 60).toString(); + + dialog.showModal(); + } else { + dialog.close(); + } + }, 1000); + } + + disconnectedCallback() { + clearInterval(this.interval); + } +} + +defineCustomElement("session-dialog", SessionDialog); diff --git a/packages/client/src/views/token-dialog.ts b/packages/client/src/views/token-dialog.ts new file mode 100644 index 00000000..966709d1 --- /dev/null +++ b/packages/client/src/views/token-dialog.ts @@ -0,0 +1,46 @@ +import { defineCustomElement } from "../custom-elements"; +import { getSecondsRemaining } from "../helpers/time"; +import { env } from "../params"; + +export class TokenDialog extends HTMLElement { + tokenExpireAtLocal?: string; + private interval?: number; + + private get secondsRemaining() { + return this.tokenExpireAtLocal + ? getSecondsRemaining(this.tokenExpireAtLocal) + : Infinity; + } + + connectedCallback() { + const dialog = this.querySelector("dialog") as HTMLDialogElement; + const form = dialog.querySelector("form") as HTMLFormElement; + + form.addEventListener("submit", (event: SubmitEvent) => { + event.preventDefault(); + const action = new FormData(form, event.submitter).get("action"); + if (action === "renew") { + this.dispatchEvent(new Event("renew")); + dialog.close(); + } else { + window.location.href = `${env("LOGOUT_URL")}`; + } + }); + + this.interval = window.setInterval(() => { + if (this.secondsRemaining < 0) { + window.location.href = `${env("LOGOUT_URL")}`; + } else if (this.secondsRemaining < 5 * 60) { + dialog.showModal(); + } else { + dialog.close(); + } + }, 1000); + } + + disconnectedCallback() { + clearInterval(this.interval); + } +} + +defineCustomElement("token-dialog", TokenDialog); diff --git a/packages/server/package.json b/packages/server/package.json index f9029215..1eaf7d6e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -24,6 +24,7 @@ "@types/bun": "1.1.6", "postcss": "8.4.39", "postcss-modules": "6.0.0", - "postcss-import": "16.1.0" + "postcss-import": "16.1.0", + "msw": "2.3.1" } } diff --git a/packages/server/src/mocks.ts b/packages/server/src/mocks.ts index 81012362..a95e6e5a 100644 --- a/packages/server/src/mocks.ts +++ b/packages/server/src/mocks.ts @@ -4,8 +4,78 @@ import notificationsMock from "./notifications-mock.json"; import { env } from "./env/server"; import testData from "./menu/main-menu-mock.json"; +const nowISOString = () => { + return new Date().toISOString(); +}; + +function addSecondsFromNow(seconds: number) { + return new Date(Date.now() + seconds * 1000).toISOString(); +} + +const addOneHourFromNow = () => { + const secondsInAnHour = 60 * 60; + return addSecondsFromNow(secondsInAnHour); +}; + +const addSixHoursFromNow = () => { + const secondsInSixHours = 6 * 60 * 60; + return addSecondsFromNow(secondsInSixHours); +}; + +const getSecondsRemaining = (futureDate: string) => { + if (!futureDate) { + return 0; + } + + const nowEpoch = new Date().getTime(); + const futureEpoch = new Date(futureDate).getTime(); + return Math.ceil((futureEpoch - nowEpoch) / 1000); +}; + export const setupMocks = () => setupServer( + http.get("http://localhost:8089/api/oauth2/session", () => + HttpResponse.json({ + session: { + created_at: nowISOString(), + ends_at: addSixHoursFromNow(), + timeout_at: addOneHourFromNow(), + ends_in_seconds: getSecondsRemaining(addSixHoursFromNow()), + active: true, + timeout_in_seconds: + getSecondsRemaining(addOneHourFromNow()), + }, + tokens: { + expire_at: addOneHourFromNow(), + refreshed_at: nowISOString(), + expire_in_seconds: getSecondsRemaining(addOneHourFromNow()), + next_auto_refresh_in_seconds: -1, + refresh_cooldown: true, + refresh_cooldown_seconds: 31, + }, + }), + ), + http.get("http://localhost:8089/api/oauth2/session/refresh", () => + HttpResponse.json({ + session: { + created_at: nowISOString(), + ends_at: addSixHoursFromNow(), + timeout_at: addOneHourFromNow(), + ends_in_seconds: getSecondsRemaining(addSixHoursFromNow()), + active: true, + timeout_in_seconds: + getSecondsRemaining(addOneHourFromNow()), + }, + tokens: { + expire_at: addOneHourFromNow(), + refreshed_at: nowISOString(), + expire_in_seconds: getSecondsRemaining(addOneHourFromNow()), + next_auto_refresh_in_seconds: -1, + refresh_cooldown: true, + refresh_cooldown_seconds: 31, + }, + }), + ), http.get(`${env.ENONICXP_SERVICES}/no.nav.navno/menu`, () => HttpResponse.json(testData), ), diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index a582cf25..8f5c9c3b 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -37,6 +37,8 @@ if (env.NODE_ENV === "development" || env.IS_LOCAL_PROD) { "/mockServiceWorker.js", serveStatic({ path: "./public/mockServiceWorker.js" }), ); + app.get("/api/oauth2/session", async ({ req }) => fetch(req.url)); + app.get("/api/oauth2/session/refresh", async ({ req }) => fetch(req.url)); } app.use(headers); diff --git a/packages/server/src/texts.ts b/packages/server/src/texts.ts index 442fc41b..78719004 100644 --- a/packages/server/src/texts.ts +++ b/packages/server/src/texts.ts @@ -36,7 +36,8 @@ const nb = { notifications_tasks_title: "Oppgaver", token_warning_title: "Du blir snart logget ut automatisk", token_warning_body: "Vil du fortsatt være innlogget?", - session_warning_title: "Du blir logget ut automatisk om ca $1 minutter", + session_warning_title: + 'Du blir logget ut automatisk om ca. $1 minutter', session_warning_body: "Avslutt det du jobber med og logg inn igjen.", yes: "Ja", no: "Nei", @@ -134,7 +135,7 @@ const en = { token_warning_title: "You will soon be logged out automatically", token_warning_body: "Would you like to stay logged in?", session_warning_title: - "You will be logged out automatically in about $1 minutes", + 'You will be logged out automatically in about $1 minutes', session_warning_body: "Avslutt det du jobber med og logg inn igjen.", yes: "Yes", no: "No", diff --git a/packages/server/src/views/components/button.ts b/packages/server/src/views/components/button.ts index 1a4eeab2..08d9a6f5 100644 --- a/packages/server/src/views/components/button.ts +++ b/packages/server/src/views/components/button.ts @@ -27,7 +27,7 @@ export const Button = ({ }: ButtonProps) => html` <${href ? "a" : "button"} ${htmlAttributes(attributes)} - ${href ? `href=${href}` : `type="${type}"`} + ${href ? html`href="${href}"` : html`type="${type}"`} class="${clsx( cls["navds-button"], { diff --git a/packages/server/src/views/footer/footer.ts b/packages/server/src/views/footer/footer.ts index d097c3a3..4cf5d49e 100644 --- a/packages/server/src/views/footer/footer.ts +++ b/packages/server/src/views/footer/footer.ts @@ -33,8 +33,7 @@ export const Footer = ({ enabled: data.shareScreen && features["dekoratoren.skjermdeling"], })} - ${data.logoutWarning ? LogoutWarning() : undefined} - ${data.feedback ? Feedback({ contactUrl }) : undefined} + ${LogoutWarning()} ${data.feedback ? Feedback({ contactUrl }) : undefined} ${simple ? SimpleFooter({ links, diff --git a/packages/server/src/views/logout-warning.ts b/packages/server/src/views/logout-warning.ts index a3b4c28c..be03e623 100644 --- a/packages/server/src/views/logout-warning.ts +++ b/packages/server/src/views/logout-warning.ts @@ -4,33 +4,59 @@ import html from "decorator-shared/html"; import { Button } from "./components/button"; import i18n from "../i18n"; -export type LogoutWarningProps = unknown; - -export function LogoutWarning() { - return html` -
-

- ${i18n("token_warning_title")} -

-

- ${i18n("token_warning_body")} -

-
- ${Button({ - content: i18n("yes"), - variant: "primary", - attributes: { - id: "logout-warning-confirm", - }, - })} - ${Button({ - content: i18n("logout"), - variant: "secondary", - attributes: { - id: "logout-warning-cancel", - }, - })} -
-
-
`; -} +export const LogoutWarning = () => html` + + + +
+

+ ${i18n("token_warning_title")} +

+

+ ${i18n("token_warning_body")} +

+
+ ${Button({ + content: i18n("yes"), + variant: "primary", + attributes: { name: "action", value: "renew" }, + type: "submit", + })} + ${Button({ + content: i18n("logout"), + variant: "secondary", + attributes: { name: "action", value: "logout" }, + type: "submit", + })} +
+
+
+
+ + +
+

+ ${i18n("session_warning_title")} +

+

+ ${i18n("session_warning_body")} +

+
+ ${Button({ + content: i18n("ok"), + variant: "primary", + attributes: { name: "action", value: "renew" }, + type: "submit", + })} + ${Button({ + content: i18n("logout"), + variant: "secondary", + attributes: { name: "action", value: "logout" }, + type: "submit", + })} +
+
+
+
+
+`;