From 8a14a9f3bb3c9f95b6f2906a01056eed9d74f1e9 Mon Sep 17 00:00:00 2001 From: John Simons Date: Tue, 18 Nov 2025 22:47:12 +1000 Subject: [PATCH 01/17] Replace moment with dayjs This should save ~286KB gzipped --- .../failedmessages/DeletedMessages.vue | 99 ++++++++++++++++++- .../src/composables/formatter.spec.ts | 24 +++-- src/Frontend/src/stores/MessageStore.ts | 2 +- 3 files changed, 116 insertions(+), 9 deletions(-) diff --git a/src/Frontend/src/components/failedmessages/DeletedMessages.vue b/src/Frontend/src/components/failedmessages/DeletedMessages.vue index 4eb0de183..37f0dce2a 100644 --- a/src/Frontend/src/components/failedmessages/DeletedMessages.vue +++ b/src/Frontend/src/components/failedmessages/DeletedMessages.vue @@ -7,7 +7,8 @@ import ServiceControlAvailable from "../ServiceControlAvailable.vue"; import MessageList, { IMessageList } from "./MessageList.vue"; import ConfirmDialog from "../ConfirmDialog.vue"; import PaginationStrip from "../../components/PaginationStrip.vue"; -import { FailedMessageStatus } from "@/resources/FailedMessage"; +import dayjs from "@/utils/dayjs"; +import { ExtendedFailedMessage } from "@/resources/FailedMessage"; import { TYPE } from "vue-toastification"; import FAIcon from "@/components/FAIcon.vue"; import { faArrowRotateRight } from "@fortawesome/free-solid-svg-icons"; @@ -26,6 +27,102 @@ const { messages, groupId, groupName, totalCount, pageNumber, selectedPeriod } = const showConfirmRestore = ref(false); const messageList = ref(); +const messages = ref([]); + +watch(pageNumber, () => loadMessages()); + +const configurationStore = useConfigurationStore(); +const { configuration } = storeToRefs(configurationStore); +const serviceControlStore = useServiceControlStore(); + +function loadMessages() { + let startDate = new Date(0); + const endDate = new Date(); + + switch (selectedPeriod.value) { + case "All Deleted": + startDate = new Date(); + startDate.setHours(startDate.getHours() - 24 * 365); + break; + case "Deleted in the last 2 Hours": + startDate = new Date(); + startDate.setHours(startDate.getHours() - 2); + break; + case "Deleted in the last 1 Day": + startDate = new Date(); + startDate.setHours(startDate.getHours() - 24); + break; + case "Deleted in the last 7 days": + startDate = new Date(); + startDate.setHours(startDate.getHours() - 24 * 7); + break; + } + return loadPagedMessages(groupId.value, pageNumber.value, "", "", startDate.toISOString(), endDate.toISOString()); +} + +async function loadGroupDetails(groupId: string) { + const [, data] = await serviceControlStore.fetchTypedFromServiceControl(`archive/groups/id/${groupId}`); + groupName.value = data.title; +} + +function loadPagedMessages(groupId?: string, page: number = 1, sortBy: string = "modified", direction: string = "desc", startDate: string = new Date(0).toISOString(), endDate: string = new Date().toISOString()) { + const dateRange = startDate + "..." + endDate; + let loadGroupDetailsPromise; + if (groupId && !groupName.value) { + loadGroupDetailsPromise = loadGroupDetails(groupId); + } + + async function loadDelMessages() { + try { + const [response, data] = await serviceControlStore.fetchTypedFromServiceControl( + `${groupId ? `recoverability/groups/${groupId}/` : ""}errors?status=archived&page=${page}&per_page=${perPage}&sort=${sortBy}&direction=${direction}&modified=${dateRange}` + ); + + totalCount.value = parseInt(response.headers.get("Total-Count") ?? "0"); + + if (messages.value.length && data.length) { + // merge the previously selected messages into the new list so we can replace them + messages.value.forEach((previousMessage) => { + const receivedMessage = data.find((m) => m.id === previousMessage.id); + if (receivedMessage) { + if (previousMessage.last_modified === receivedMessage.last_modified) { + receivedMessage.retryInProgress = previousMessage.retryInProgress; + receivedMessage.deleteInProgress = previousMessage.deleteInProgress; + } + + receivedMessage.selected = previousMessage.selected; + } + }); + } + messages.value = updateMessagesScheduledDeletionDate(data); + } catch (err) { + console.log(err); + const result = { + message: "error", + }; + return result; + } + } + + const loadDelMessagesPromise = loadDelMessages(); + + if (loadGroupDetailsPromise) { + return Promise.all([loadGroupDetailsPromise, loadDelMessagesPromise]); + } + + return loadDelMessagesPromise; +} + +function updateMessagesScheduledDeletionDate(messages: ExtendedFailedMessage[]) { + //check deletion time + messages.forEach((message) => { + message.error_retention_period = dayjs.duration(configuration.value?.data_retention.error_retention_period ?? "PT0S").asHours(); + const countdown = dayjs(message.last_modified).add(message.error_retention_period, "hours"); + message.delete_soon = countdown < dayjs(); + message.deleted_in = countdown.format(); + }); + return messages; +} function numberSelected() { return messageList.value?.getSelectedMessages()?.length ?? 0; diff --git a/src/Frontend/src/composables/formatter.spec.ts b/src/Frontend/src/composables/formatter.spec.ts index 02999e1ba..f48281b79 100644 --- a/src/Frontend/src/composables/formatter.spec.ts +++ b/src/Frontend/src/composables/formatter.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { useFormatTime, useGetDayDiffFromToday, useFormatLargeNumber, createDateWithDayOffset } from "./formatter"; +import { useFormatTime, useGetDayDiffFromToday, useFormatLargeNumber } from "./formatter"; describe("useFormatTime", () => { describe("milliseconds formatting", () => { @@ -100,37 +100,47 @@ describe("useFormatTime", () => { describe("useGetDayDiffFromToday", () => { test("returns 0 for today's date", () => { - const today = createDateWithDayOffset(); + const today = new Date(); + today.setHours(12, 0, 0, 0); const result = useGetDayDiffFromToday(today.toISOString()); expect(result).toBe(0); }); test("returns positive number for future dates", () => { - const tomorrow = createDateWithDayOffset(1); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(12, 0, 0, 0); const result = useGetDayDiffFromToday(tomorrow.toISOString()); expect(result).toBe(1); }); test("returns negative number for past dates", () => { - const yesterday = createDateWithDayOffset(-1); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(12, 0, 0, 0); const result = useGetDayDiffFromToday(yesterday.toISOString()); expect(result).toBe(-1); }); test("returns 7 for date 7 days in the future", () => { - const futureDate = createDateWithDayOffset(7); + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 7); + futureDate.setHours(12, 0, 0, 0); const result = useGetDayDiffFromToday(futureDate.toISOString()); expect(result).toBe(7); }); test("returns -30 for date 30 days in the past", () => { - const pastDate = createDateWithDayOffset(-30); + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 30); + pastDate.setHours(12, 0, 0, 0); const result = useGetDayDiffFromToday(pastDate.toISOString()); expect(result).toBe(-30); }); test("handles dates without Z suffix", () => { - const date = createDateWithDayOffset(); + const date = new Date(); + date.setHours(12, 0, 0, 0); const isoString = date.toISOString().replace("Z", ""); const result = useGetDayDiffFromToday(isoString); expect(result).toBe(0); diff --git a/src/Frontend/src/stores/MessageStore.ts b/src/Frontend/src/stores/MessageStore.ts index 447b9d3e4..5077dd8ca 100644 --- a/src/Frontend/src/stores/MessageStore.ts +++ b/src/Frontend/src/stores/MessageStore.ts @@ -78,7 +78,7 @@ export const useMessageStore = defineStore("MessageStore", () => { const areSimpleHeadersSupported = environmentStore.serviceControlIsGreaterThan("5.2.0"); const { configuration } = storeToRefs(configStore); - const error_retention_period = computed(() => timeSpanToDuration(configuration.value?.data_retention?.error_retention_period).asHours()); + const error_retention_period = computed(() => dayjs.duration(configuration.value?.data_retention?.error_retention_period ?? "PT0S").asHours()); async function loadEditAndRetryConfiguration() { try { From a82a011a9b5ad35624c62261ae779dddc265dd45 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Mon, 24 Nov 2025 13:04:57 +0800 Subject: [PATCH 02/17] WIP - Add authentication --- src/Frontend/package-lock.json | 57 +++-- src/Frontend/package.json | 2 + src/Frontend/public/silent-renew.html | 20 ++ src/Frontend/src/App.vue | 81 ++++++- .../configuration/PlatformConnections.vue | 5 +- src/Frontend/src/composables/useAuth.ts | 162 ++++++++++++++ .../src/composables/useAuthenticatedFetch.ts | 20 ++ src/Frontend/src/config/auth.config.ts | 32 +++ src/Frontend/src/stores/AuthStore.ts | 54 +++++ .../src/stores/EnvironmentAndVersionsStore.ts | 9 +- .../src/stores/ServiceControlStore.ts | 197 ++++++++++++++++++ src/Frontend/src/types/auth.ts | 7 + src/Frontend/vite.config.ts | 2 + 13 files changed, 603 insertions(+), 45 deletions(-) create mode 100644 src/Frontend/public/silent-renew.html create mode 100644 src/Frontend/src/composables/useAuth.ts create mode 100644 src/Frontend/src/composables/useAuthenticatedFetch.ts create mode 100644 src/Frontend/src/config/auth.config.ts create mode 100644 src/Frontend/src/stores/AuthStore.ts create mode 100644 src/Frontend/src/stores/ServiceControlStore.ts create mode 100644 src/Frontend/src/types/auth.ts diff --git a/src/Frontend/package-lock.json b/src/Frontend/package-lock.json index bdd647f51..ce32c4229 100644 --- a/src/Frontend/package-lock.json +++ b/src/Frontend/package-lock.json @@ -28,6 +28,8 @@ "diff": "8.0.2", "hex-to-css-filter": "6.0.0", "lossless-json": "4.3.0", + "moment": "2.30.1", + "oidc-client-ts": "3.4.1", "pinia": "3.0.4", "vue": "3.5.25", "vue-codemirror6": "1.4.1", @@ -126,7 +128,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -616,7 +617,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz", "integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -629,7 +629,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz", "integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", @@ -666,7 +665,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -690,7 +688,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz", "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", @@ -702,7 +699,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", @@ -714,7 +710,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -724,7 +719,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -820,7 +814,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -844,7 +837,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1529,7 +1521,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz", "integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" }, @@ -2005,7 +1996,6 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -2311,7 +2301,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2578,7 +2567,6 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -2994,7 +2982,6 @@ "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.0.tgz", "integrity": "sha512-keW9HGaEZEe4SKYtrzp5E+qSGJ5/z+9i2yRDtCr3o72IUnS0Ns1qQNsIbGGz0ygpKzg6LdtbVLWeYAvl3dzLQA==", "license": "MIT", - "peer": true, "dependencies": { "@vueuse/core": "^10.5.0", "d3-drag": "^3.0.0", @@ -3147,7 +3134,6 @@ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@vue/compiler-core": "3.5.25", @@ -3468,7 +3454,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3718,7 +3703,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3956,7 +3940,6 @@ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", @@ -4183,7 +4166,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4668,7 +4650,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4729,7 +4710,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6079,6 +6059,15 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6520,6 +6509,18 @@ "dev": true, "license": "MIT" }, + "node_modules/oidc-client-ts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", + "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -6687,7 +6688,6 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -6772,7 +6772,6 @@ "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7399,8 +7398,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/superjson": { "version": "2.2.2", @@ -7525,7 +7523,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7662,7 +7659,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7809,7 +7805,6 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -8125,7 +8120,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8139,7 +8133,6 @@ "integrity": "sha512-pmW4GCKQ8t5Ko1jYjC3SqOr7TUKN7uHOHB/XGsAIb69eYu6d1ionGSsb5H9chmPf+WeXt0VE7jTXsB1IvWoNbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.12", "@vitest/mocker": "4.0.12", @@ -8241,7 +8234,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.25", "@vue/compiler-sfc": "3.5.25", @@ -8389,7 +8381,6 @@ "integrity": "sha512-L/G9IUjOWhBU0yun89rv8fKqmKC+T0HfhrFjlIml71WpfBv9eb4E9Bev8FMbyueBIU9vxQqbd+oOsVcDa5amGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@volar/typescript": "2.4.23", "@vue/language-core": "3.1.5" diff --git a/src/Frontend/package.json b/src/Frontend/package.json index c674520cf..3807ff9cf 100644 --- a/src/Frontend/package.json +++ b/src/Frontend/package.json @@ -40,6 +40,8 @@ "diff": "8.0.2", "hex-to-css-filter": "6.0.0", "lossless-json": "4.3.0", + "moment": "2.30.1", + "oidc-client-ts": "3.4.1", "pinia": "3.0.4", "vue": "3.5.25", "vue-codemirror6": "1.4.1", diff --git a/src/Frontend/public/silent-renew.html b/src/Frontend/public/silent-renew.html new file mode 100644 index 000000000..5cc4e681a --- /dev/null +++ b/src/Frontend/public/silent-renew.html @@ -0,0 +1,20 @@ + + + + + Silent Renew + + + + + diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue index 1a69d6bc4..d5c1a783b 100644 --- a/src/Frontend/src/App.vue +++ b/src/Frontend/src/App.vue @@ -1,18 +1,87 @@ + + diff --git a/src/Frontend/src/components/configuration/PlatformConnections.vue b/src/Frontend/src/components/configuration/PlatformConnections.vue index 6f1ad46fe..2f97ee54e 100644 --- a/src/Frontend/src/components/configuration/PlatformConnections.vue +++ b/src/Frontend/src/components/configuration/PlatformConnections.vue @@ -5,6 +5,7 @@ import FAIcon from "@/components/FAIcon.vue"; import useConnectionsAndStatsAutoRefresh from "@/composables/useConnectionsAndStatsAutoRefresh"; import serviceControlClient from "@/components/serviceControlClient"; import monitoringClient from "../monitoring/monitoringClient"; +import { authFetch } from "@/composables/useAuthenticatedFetch"; const { store: connectionStore } = useConnectionsAndStatsAutoRefresh(); const connectionState = connectionStore.connectionState; @@ -24,7 +25,7 @@ async function testServiceControlUrl() { if (localServiceControlUrl.value) { testingServiceControl.value = true; try { - const response = await fetch(localServiceControlUrl.value); + const response = await authFetch(localServiceControlUrl.value); serviceControlValid.value = response.ok && response.headers.has("X-Particular-Version"); } catch { serviceControlValid.value = false; @@ -43,7 +44,7 @@ async function testMonitoringUrl() { } try { - const response = await fetch(localMonitoringUrl.value + "monitored-endpoints"); + const response = await authFetch(localMonitoringUrl.value + "monitored-endpoints"); monitoringValid.value = response.ok && response.headers.has("X-Particular-Version"); } catch { monitoringValid.value = false; diff --git a/src/Frontend/src/composables/useAuth.ts b/src/Frontend/src/composables/useAuth.ts new file mode 100644 index 000000000..137d70d73 --- /dev/null +++ b/src/Frontend/src/composables/useAuth.ts @@ -0,0 +1,162 @@ +import { useAuthStore } from "@/stores/AuthStore"; +import type { AuthConfig } from "@/types/auth"; +import { UserManager, type User } from "oidc-client-ts"; + +let userManager: UserManager | null = null; + +/** + * Authentication composable using 'oidc-client-ts' package + * Supports any OIDC-compliant identity provider (Entra ID, Auth0, Okta, etc.) + */ +export function useAuth() { + const authStore = useAuthStore(); + + function initializeUserManager(config: AuthConfig): UserManager { + if (!userManager) { + userManager = new UserManager(config); + + // Set up event handlers + userManager.events.addUserLoaded((user: User) => { + console.debug("User loaded:", user.profile); + authStore.setToken(user.access_token); + }); + + userManager.events.addUserUnloaded(() => { + console.debug("User unloaded"); + authStore.clearToken(); + }); + + userManager.events.addAccessTokenExpiring(async () => { + console.debug("Access token expiring, attempting silent renewal..."); + try { + await userManager?.signinSilent(); + } catch (error) { + console.error("Silent token renewal failed:", error); + } + }); + + userManager.events.addAccessTokenExpired(() => { + console.debug("Access token expired"); + authStore.clearToken(); + }); + + userManager.events.addSilentRenewError((error) => { + console.error("Silent renew error:", error); + }); + } + + return userManager; + } + + /** + * Gets the current user from the UserManager + */ + async function getUser(): Promise { + if (!userManager) { + return null; + } + return await userManager.getUser(); + } + + /** + * Attempts to authenticate the user + * This checks for existing authentication or handles the callback from the identity provider + */ + async function authenticate(config: AuthConfig): Promise { + const manager = initializeUserManager(config); + + try { + // Check if we're returning from the identity provider (callback) + // Look for specific OAuth parameters in the URL + const params = new URLSearchParams(window.location.search); + const hasCode = params.has("code"); + const hasState = params.has("state"); + const hasError = params.has("error"); + + if (hasCode && hasState) { + // This is an OAuth callback with authorization code + console.debug("Processing OAuth callback..."); + authStore.setAuthenticating(true); + try { + const user = await manager.signinCallback(); + console.debug("Signin callback successful"); + if (user) { + authStore.setToken(user.access_token); + // Clean up URL by removing OAuth parameters + window.history.replaceState({}, document.title, window.location.pathname); + return true; + } + } catch (error) { + console.error("Signin callback error details:", { + error, + errorMessage: error instanceof Error ? error.message : "Unknown error", + errorStack: error instanceof Error ? error.stack : undefined, + }); + authStore.setAuthError(error instanceof Error ? error.message : "Callback failed"); + // Don't continue - callback failed, user needs to try again + return false; + } finally { + authStore.setAuthenticating(false); + } + } else if (hasError) { + // OAuth error in callback + const errorDescription = params.get("error_description") || params.get("error"); + console.error("OAuth error:", errorDescription); + authStore.setAuthError(errorDescription || "Authentication failed"); + return false; + } + + // Check for existing valid user session + const user = await manager.getUser(); + if (user && !user.expired) { + console.debug("Existing user session found", user.profile); + authStore.setToken(user.access_token); + return true; + } + + // No valid session, initiate login + authStore.setAuthenticating(true); + await manager.signinRedirect(); + return false; // Will redirect, so this won't actually return + } catch (error) { + authStore.setAuthenticating(false); + const errorMessage = error instanceof Error ? error.message : "Unknown authentication error"; + authStore.setAuthError(errorMessage); + console.error("Authentication error:", error); + throw error; + } + } + + /** + * Logs out the user and optionally redirects to the identity provider's logout endpoint + */ + async function logout(redirectToIdp: boolean = true): Promise { + if (!userManager) { + authStore.clearToken(); + return; + } + + try { + if (redirectToIdp) { + // Sign out and redirect to the identity provider + await userManager.signoutRedirect(); + } else { + // Remove local session only + await userManager.removeUser(); + authStore.clearToken(); + } + } catch (error) { + console.error("Logout error:", error); + authStore.clearToken(); + } + } + + return { + authenticate, + logout, + getUser, + isAuthenticated: authStore.isAuthenticated, + isAuthenticating: authStore.isAuthenticating, + authError: authStore.authError, + }; +} diff --git a/src/Frontend/src/composables/useAuthenticatedFetch.ts b/src/Frontend/src/composables/useAuthenticatedFetch.ts new file mode 100644 index 000000000..30486b24b --- /dev/null +++ b/src/Frontend/src/composables/useAuthenticatedFetch.ts @@ -0,0 +1,20 @@ +import { useAuthStore } from "@/stores/AuthStore"; + +/** + * Authenticated fetch wrapper that automatically includes JWT token + * in the Authorization header for all requests + */ +export async function authFetch(input: RequestInfo, init?: RequestInit): Promise { + const authStore = useAuthStore(); + const token = authStore.token; + + // todo: potentially handle token refresh here if expired, however it shouldnt be required due to silent renew + if (!token) { + throw new Error("No authentication token available. Please authenticate first."); + } + + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${token}`); + + return await fetch(input, { ...init, headers }); +} diff --git a/src/Frontend/src/config/auth.config.ts b/src/Frontend/src/config/auth.config.ts new file mode 100644 index 000000000..dc31bb99a --- /dev/null +++ b/src/Frontend/src/config/auth.config.ts @@ -0,0 +1,32 @@ +import type { AuthConfig } from "@/types/auth"; +import { WebStorageStateStore } from "oidc-client-ts"; + +// Retrieve configuration values from environment variables +const authorityUrl = import.meta.env.VITE_ENTRA_AUTHORITY; +const clientId = import.meta.env.VITE_ENTRA_CLIENT_ID; +const serviceControlApiScope = import.meta.env.VITE_ENTRA_SERVICE_CONTROL_API_SCOPE; + +export const authConfig: AuthConfig = { + authority: authorityUrl, + client_id: clientId, + redirect_uri: window.location.origin, + // todo: Consider a dedicated logout redirect page if needed + post_logout_redirect_uri: window.location.origin, + // Use authorization code flow with PKCE (recommended for SPAs) + response_type: "code", + // openid, profile, email are standard OIDC scopes + // offline_access enables refresh tokens + scope: `${serviceControlApiScope} openid profile email offline_access`, + // Automatically attempt silent token renewal before expiration + automaticSilentRenew: true, + // Load user profile from the UserInfo endpoint + // Enable this if you need additional user profile data from Graph + loadUserInfo: false, + includeIdTokenInSilentRenew: true, + // Silent renew configuration + silent_redirect_uri: window.location.origin + "/silent-renew.html", + // Optional: Filter OIDC protocol claims from profile + filterProtocolClaims: true, + // Storage configuration - explicitly use sessionStorage + userStore: new WebStorageStateStore({ store: window.sessionStorage }), +}; diff --git a/src/Frontend/src/stores/AuthStore.ts b/src/Frontend/src/stores/AuthStore.ts new file mode 100644 index 000000000..1d2a2b9ed --- /dev/null +++ b/src/Frontend/src/stores/AuthStore.ts @@ -0,0 +1,54 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +export const useAuthStore = defineStore("auth", () => { + const token = ref(null); + const isAuthenticated = ref(false); + const isAuthenticating = ref(false); + const authError = ref(null); + + function setToken(newToken: string | null) { + token.value = newToken; + isAuthenticated.value = !!newToken; + + if (newToken) { + // Store token in sessionStorage for persistence across page reloads + sessionStorage.setItem("auth_token", newToken); + } else { + sessionStorage.removeItem("auth_token"); + } + } + + function clearToken() { + setToken(null); + authError.value = null; + } + + function loadTokenFromStorage() { + const storedToken = sessionStorage.getItem("auth_token"); + if (storedToken) { + token.value = storedToken; + isAuthenticated.value = true; + } + } + + function setAuthenticating(value: boolean) { + isAuthenticating.value = value; + } + + function setAuthError(error: string | null) { + authError.value = error; + } + + return { + token, + isAuthenticated, + isAuthenticating, + authError, + setToken, + clearToken, + loadTokenFromStorage, + setAuthenticating, + setAuthError, + }; +}); diff --git a/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts b/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts index 56ab03151..cf6712c59 100644 --- a/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts +++ b/src/Frontend/src/stores/EnvironmentAndVersionsStore.ts @@ -6,6 +6,7 @@ import { acceptHMRUpdate, defineStore } from "pinia"; import { computed, reactive } from "vue"; import serviceControlClient from "@/components/serviceControlClient"; import monitoringClient from "@/components/monitoring/monitoringClient"; +import { authFetch } from "@/composables/useAuthenticatedFetch"; export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersionsStore", () => { const environment = reactive({ @@ -104,9 +105,9 @@ export const useEnvironmentAndVersionsStore = defineStore("EnvironmentAndVersion }; }); -async function getData(url: string) { +async function getData(url: string, authenticated = false) { try { - const response = await fetch(url); + const response = await (authenticated ? authFetch(url) : fetch(url)); return (await response.json()) as unknown as Release[]; } catch (e) { console.log(e); @@ -124,8 +125,8 @@ async function useServiceProductUrls() { const spURL = "https://platformupdate.particular.net/servicepulse.txt"; const scURL = "https://platformupdate.particular.net/servicecontrol.txt"; - const servicePulse = getData(spURL); - const serviceControl = getData(scURL); + const servicePulse = getData(spURL, false); + const serviceControl = getData(scURL, false); const [sp, sc] = await Promise.all([servicePulse, serviceControl]); const latestSP = sp[0]; diff --git a/src/Frontend/src/stores/ServiceControlStore.ts b/src/Frontend/src/stores/ServiceControlStore.ts new file mode 100644 index 000000000..40597dc1c --- /dev/null +++ b/src/Frontend/src/stores/ServiceControlStore.ts @@ -0,0 +1,197 @@ +import { acceptHMRUpdate, defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { authFetch } from "@/composables/useAuthenticatedFetch"; + +export const useServiceControlStore = defineStore("ServiceControlStore", () => { + const serviceControlUrl = ref(); + const monitoringUrl = ref(); + + const isMonitoringDisabled = computed(() => monitoringUrl.value == null || monitoringUrl.value === "" || monitoringUrl.value === "!"); + const isMonitoringEnabled = computed(() => !isMonitoringDisabled.value); + + function getServiceControlUrl() { + if (!serviceControlUrl.value) { + refresh(); + } + if (!serviceControlUrl.value) { + throw new Error("Service Control URL is not configured"); + } + return serviceControlUrl.value; + } + + function getMonitoringUrl() { + if (!monitoringUrl.value) refresh(); + return monitoringUrl.value; + } + + function refresh() { + const params = getParams(); + const scu = getParameter(params, "scu"); + const mu = getParameter(params, "mu"); + + if (scu) { + serviceControlUrl.value = scu.value; + window.localStorage.setItem("scu", serviceControlUrl.value); + console.debug(`ServiceControl Url found in QS and stored in local storage: ${serviceControlUrl.value}`); + } else if (window.localStorage.getItem("scu")) { + serviceControlUrl.value = window.localStorage.getItem("scu"); + console.debug(`ServiceControl Url, not in QS, found in local storage: ${serviceControlUrl.value}`); + } else if (window.defaultConfig && window.defaultConfig.service_control_url) { + serviceControlUrl.value = window.defaultConfig.service_control_url; + console.debug(`setting ServiceControl Url to its default value: ${window.defaultConfig.service_control_url}`); + } else { + console.warn("ServiceControl Url is not defined."); + } + + if (mu) { + monitoringUrl.value = mu.value; + window.localStorage.setItem("mu", monitoringUrl.value); + console.debug(`Monitoring Url found in QS and stored in local storage: ${monitoringUrl.value}`); + } else if (window.localStorage.getItem("mu")) { + monitoringUrl.value = window.localStorage.getItem("mu"); + console.debug(`Monitoring Url, not in QS, found in local storage: ${monitoringUrl.value}`); + } else if (window.defaultConfig && window.defaultConfig.monitoring_urls && window.defaultConfig.monitoring_urls.length) { + monitoringUrl.value = window.defaultConfig.monitoring_urls[0]; + console.debug(`setting Monitoring Url to its default value: ${window.defaultConfig.monitoring_urls[0]}`); + } else { + console.warn("Monitoring Url is not defined."); + } + } + + async function fetchFromServiceControl(suffix: string, options?: { cache?: RequestCache }) { + const requestOptions: RequestInit = { + method: "GET", + cache: options?.cache ?? "default", // Default if not specified + headers: { + Accept: "application/json", + }, + }; + return await authFetch(`${getServiceControlUrl()}${suffix}`, requestOptions); + } + + async function fetchTypedFromServiceControl(suffix: string): Promise<[Response, T]> { + const response = await authFetch(`${getServiceControlUrl()}${suffix}`); + if (!response.ok) throw new Error(response.statusText ?? "No response"); + const data = await response.json(); + + return [response, data]; + } + + async function fetchTypedFromMonitoring(suffix: string): Promise<[Response?, T?]> { + if (isMonitoringDisabled.value) { + return []; + } + + const response = await authFetch(`${getMonitoringUrl()}${suffix}`); + const data = await response.json(); + + return [response, data]; + } + + async function postToServiceControl(suffix: string, payload: object | null = null) { + const requestOptions: RequestInit = { + method: "POST", + }; + if (payload != null) { + requestOptions.headers = { "Content-Type": "application/json" }; + requestOptions.body = JSON.stringify(payload); + } + return await authFetch(`${getServiceControlUrl()}${suffix}`, requestOptions); + } + + async function putToServiceControl(suffix: string, payload: object | null) { + const requestOptions: RequestInit = { + method: "PUT", + }; + if (payload != null) { + requestOptions.headers = { "Content-Type": "application/json" }; + requestOptions.body = JSON.stringify(payload); + } + return await authFetch(`${getServiceControlUrl()}${suffix}`, requestOptions); + } + + async function deleteFromServiceControl(suffix: string) { + const requestOptions: RequestInit = { + method: "DELETE", + }; + return await authFetch(`${getServiceControlUrl()}${suffix}`, requestOptions); + } + + async function deleteFromMonitoring(suffix: string) { + const requestOptions = { + method: "DELETE", + }; + return await authFetch(`${getMonitoringUrl()}${suffix}`, requestOptions); + } + + async function optionsFromMonitoring() { + if (isMonitoringDisabled.value) { + return Promise.resolve(null); + } + + const requestOptions = { + method: "OPTIONS", + }; + return await authFetch(getMonitoringUrl() ?? "", requestOptions); + } + + async function patchToServiceControl(suffix: string, payload: object | null) { + const requestOptions: RequestInit = { + method: "PATCH", + }; + if (payload != null) { + requestOptions.headers = { "Content-Type": "application/json" }; + requestOptions.body = JSON.stringify(payload); + } + return await authFetch(`${getServiceControlUrl()}${suffix}`, requestOptions); + } + + return { + refresh, + serviceControlUrl, + monitoringUrl, + isMonitoringDisabled, + isMonitoringEnabled, + fetchFromServiceControl, + fetchTypedFromServiceControl, + fetchTypedFromMonitoring, + putToServiceControl, + postToServiceControl, + patchToServiceControl, + deleteFromServiceControl, + deleteFromMonitoring, + optionsFromMonitoring, + }; +}); + +interface Param { + name: string; + value: string; +} + +function getParams() { + const params: Param[] = []; + + if (!window.location.search) return params; + + const searchParams = window.location.search.split("&"); + + searchParams.forEach((p) => { + p = p.startsWith("?") ? p.substring(1, p.length) : p; + const singleParam = p.split("="); + params.push({ name: singleParam[0], value: singleParam[1] }); + }); + return params; +} + +function getParameter(params: Param[], key: string) { + return params.find((param) => { + return param.name === key; + }); +} + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useServiceControlStore, import.meta.hot)); +} + +export type ServiceControlStore = ReturnType; diff --git a/src/Frontend/src/types/auth.ts b/src/Frontend/src/types/auth.ts new file mode 100644 index 000000000..cdcafba80 --- /dev/null +++ b/src/Frontend/src/types/auth.ts @@ -0,0 +1,7 @@ +import type { UserManagerSettings } from "oidc-client-ts"; + +/** + * Extended OIDC configuration using 'oidc-client-ts' package + * This provides type-safe configuration for any OIDC-compliant identity provider + */ +export type AuthConfig = UserManagerSettings; diff --git a/src/Frontend/vite.config.ts b/src/Frontend/vite.config.ts index f8b73aa88..46383327e 100644 --- a/src/Frontend/vite.config.ts +++ b/src/Frontend/vite.config.ts @@ -25,6 +25,8 @@ const port = 5173; const defaultUrls = [ "http://10.211.55.3:*", // The default Parallels url to access Windows VM "http://localhost:*", + "https://login.microsoftonline.com", // Entra ID authentication + "https://graph.microsoft.com", // Microsoft Graph API ]; // https://vitejs.dev/config/ From d5131b789c3172a862c9e29f7767ee276bed9ffe Mon Sep 17 00:00:00 2001 From: Jason Taylor Date: Wed, 26 Nov 2025 10:31:38 +1000 Subject: [PATCH 03/17] Load OIDC configuration from ServiceControl API instead of local env --- src/Frontend/src/App.vue | 14 ++++- .../src/composables/useAuthenticatedFetch.ts | 6 ++ src/Frontend/src/config/auth.config.ts | 32 ----------- src/Frontend/src/stores/AuthStore.ts | 56 +++++++++++++++++++ 4 files changed, 73 insertions(+), 35 deletions(-) delete mode 100644 src/Frontend/src/config/auth.config.ts diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue index d5c1a783b..7b514cff8 100644 --- a/src/Frontend/src/App.vue +++ b/src/Frontend/src/App.vue @@ -9,20 +9,28 @@ import BackendChecksNotifications from "@/components/BackendChecksNotifications. import { useAuth } from "@/composables/useAuth"; import { storeToRefs } from "pinia"; import { useAuthStore } from "@/stores/AuthStore"; -import { authConfig } from "@/config/auth.config"; const { authenticate } = useAuth(); const authStore = useAuthStore(); const { isAuthenticating, isAuthenticated } = storeToRefs(authStore); onMounted(async () => { - // Attempt to authenticate when the app first loads try { + // Attempt to authenticate when the app first loads + await authStore.refresh(); + + // Check if auth config is available + if (!authStore.authConfig) { + console.debug("Auth configuration not available, skipping authentication"); + authStore.setAuthenticating(false); + return; + } + // The authenticate function will: // 1. Check if we're returning from the identity provider (handle callback) // 2. Check for an existing valid session // 3. If no valid session, redirect to the identity provider - const authenticated = await authenticate(authConfig); + const authenticated = await authenticate(authStore.authConfig); if (authenticated) { console.debug("User authenticated successfully"); diff --git a/src/Frontend/src/composables/useAuthenticatedFetch.ts b/src/Frontend/src/composables/useAuthenticatedFetch.ts index 30486b24b..76396ecab 100644 --- a/src/Frontend/src/composables/useAuthenticatedFetch.ts +++ b/src/Frontend/src/composables/useAuthenticatedFetch.ts @@ -8,6 +8,12 @@ export async function authFetch(input: RequestInfo, init?: RequestInit): Promise const authStore = useAuthStore(); const token = authStore.token; + // Allow unauthenticated requests to the auth configuration endpoint + const url = typeof input === "string" ? input : input.url; + if (url.includes("/api/authentication/configuration")) { + return await fetch(input, init); + } + // todo: potentially handle token refresh here if expired, however it shouldnt be required due to silent renew if (!token) { throw new Error("No authentication token available. Please authenticate first."); diff --git a/src/Frontend/src/config/auth.config.ts b/src/Frontend/src/config/auth.config.ts deleted file mode 100644 index dc31bb99a..000000000 --- a/src/Frontend/src/config/auth.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { AuthConfig } from "@/types/auth"; -import { WebStorageStateStore } from "oidc-client-ts"; - -// Retrieve configuration values from environment variables -const authorityUrl = import.meta.env.VITE_ENTRA_AUTHORITY; -const clientId = import.meta.env.VITE_ENTRA_CLIENT_ID; -const serviceControlApiScope = import.meta.env.VITE_ENTRA_SERVICE_CONTROL_API_SCOPE; - -export const authConfig: AuthConfig = { - authority: authorityUrl, - client_id: clientId, - redirect_uri: window.location.origin, - // todo: Consider a dedicated logout redirect page if needed - post_logout_redirect_uri: window.location.origin, - // Use authorization code flow with PKCE (recommended for SPAs) - response_type: "code", - // openid, profile, email are standard OIDC scopes - // offline_access enables refresh tokens - scope: `${serviceControlApiScope} openid profile email offline_access`, - // Automatically attempt silent token renewal before expiration - automaticSilentRenew: true, - // Load user profile from the UserInfo endpoint - // Enable this if you need additional user profile data from Graph - loadUserInfo: false, - includeIdTokenInSilentRenew: true, - // Silent renew configuration - silent_redirect_uri: window.location.origin + "/silent-renew.html", - // Optional: Filter OIDC protocol claims from profile - filterProtocolClaims: true, - // Storage configuration - explicitly use sessionStorage - userStore: new WebStorageStateStore({ store: window.sessionStorage }), -}; diff --git a/src/Frontend/src/stores/AuthStore.ts b/src/Frontend/src/stores/AuthStore.ts index 1d2a2b9ed..4bbe1ee63 100644 --- a/src/Frontend/src/stores/AuthStore.ts +++ b/src/Frontend/src/stores/AuthStore.ts @@ -1,11 +1,64 @@ import { defineStore } from "pinia"; import { ref } from "vue"; +import { useServiceControlStore } from "./ServiceControlStore"; +import type { AuthConfig } from "@/types/auth"; +import { WebStorageStateStore } from "oidc-client-ts"; + +interface AuthConfigResponse { + enabled: boolean; + client_id: string; + authority: string; + api_scope: string; +} export const useAuthStore = defineStore("auth", () => { + const serviceControlStore = useServiceControlStore(); + const token = ref(null); const isAuthenticated = ref(false); const isAuthenticating = ref(false); const authError = ref(null); + const authConfig = ref(null); + const loading = ref(false); + + async function refresh() { + loading.value = true; + try { + const config = await getAuthConfig(); + if (config) { + authConfig.value = transformToAuthConfig(config); + } + } finally { + loading.value = false; + } + } + + async function getAuthConfig() { + try { + const [, data] = await serviceControlStore.fetchTypedFromServiceControl("authentication/configuration"); + return data; + } catch (err) { + console.error("Error fetching auth config information", err); + return null; + } + } + + function transformToAuthConfig(config: AuthConfigResponse): AuthConfig { + return { + authority: config.authority, + client_id: config.client_id, + redirect_uri: window.location.origin, + post_logout_redirect_uri: window.location.origin, + response_type: "code", + scope: `${config.api_scope} openid profile email offline_access`, + automaticSilentRenew: true, + loadUserInfo: false, + includeIdTokenInSilentRenew: true, + silent_redirect_uri: window.location.origin + "/silent-renew.html", + filterProtocolClaims: true, + userStore: new WebStorageStateStore({ store: window.sessionStorage }), + }; + } function setToken(newToken: string | null) { token.value = newToken; @@ -45,6 +98,9 @@ export const useAuthStore = defineStore("auth", () => { isAuthenticated, isAuthenticating, authError, + authConfig, + loading, + refresh, setToken, clearToken, loadTokenFromStorage, From a568df16a4192ae4ca461c2ebe5bad30096777b9 Mon Sep 17 00:00:00 2001 From: Jason Taylor Date: Wed, 26 Nov 2025 12:07:27 +1000 Subject: [PATCH 04/17] Adding support for enabling/disabling authentication --- src/Frontend/src/App.vue | 16 +++++++--- .../src/composables/useAuthenticatedFetch.ts | 30 +++++++++++++------ src/Frontend/src/stores/AuthStore.ts | 16 +++++++--- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/Frontend/src/App.vue b/src/Frontend/src/App.vue index 7b514cff8..5fa63f29e 100644 --- a/src/Frontend/src/App.vue +++ b/src/Frontend/src/App.vue @@ -1,5 +1,5 @@