From 7c5ef1ba4245ee010ec81f1edfc741b495ac0684 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Wed, 14 Oct 2026 12:11:03 +0200 Subject: [PATCH 1/4] feat(header): link the hub admin and account console in the avatar menu --- app/components/header/AvatarButton.vue | 22 +++++++++++ app/utils/hub-console-url.ts | 44 +++++++++++++++++++++ test/components/header/AvatarButton.spec.ts | 16 ++++++++ test/mockapi/setup.ts | 2 + test/utils/hub-console-url.test.ts | 33 ++++++++++++++++ 5 files changed, 117 insertions(+) create mode 100644 app/utils/hub-console-url.ts create mode 100644 test/utils/hub-console-url.test.ts diff --git a/app/components/header/AvatarButton.vue b/app/components/header/AvatarButton.vue index 545e3dd3..e1493a89 100644 --- a/app/components/header/AvatarButton.vue +++ b/app/components/header/AvatarButton.vue @@ -4,6 +4,7 @@ import Button from "primevue/button"; import { useRuntimeConfig } from "nuxt/app"; import { useToast } from "primevue/usetoast"; import CleanupDialog from "~/components/header/CleanupDialog.vue"; +import { buildHubConsoleLinks } from "~/utils/hub-console-url"; const { signIn, signOut } = useAuth(); const { status: authStatus, data: authData } = useAuthState(); @@ -16,6 +17,13 @@ const config = useRuntimeConfig(); const baseUrl = new URL(config.public.baseUrl as string).origin; const idpProvider = config.public.idpProvider; const victoriaLogsUrl = config.public.victoriaLogsUrl as string; +const hubConsoleLinks = + idpProvider === "hub" + ? buildHubConsoleLinks( + config.public.idpIssuer as string | undefined, + window.location.origin, + ) + : undefined; const isAuthenticated = computed(() => authStatus.value === "authenticated"); @@ -41,6 +49,20 @@ const menuItems = computed(() => [ : attemptSignIn(); }, }, + { + label: "Hub Account", + icon: "pi pi-user-edit", + url: hubConsoleLinks?.account, + target: "_blank", + visible: !!hubConsoleLinks, + }, + { + label: "Hub Admin Console", + icon: "pi pi-external-link", + url: hubConsoleLinks?.admin, + target: "_blank", + visible: !!hubConsoleLinks, + }, { label: "Node Keycloak Admin", icon: "pi pi-external-link", diff --git a/app/utils/hub-console-url.ts b/app/utils/hub-console-url.ts new file mode 100644 index 00000000..e42fa9da --- /dev/null +++ b/app/utils/hub-console-url.ts @@ -0,0 +1,44 @@ +export type HubConsoleLinks = { + admin: string; + account: string; +}; + +/** + * Derive the Hub's Authup console links from the OIDC issuer the node UI signs in with, + * e.g. "https://auth.privateaim.net/realms/my-realm". + * + * The issuer carries both the Authup base URL and the realm. Authup only lets a user log + * in through a client of the user's own realm, so the issuer realm is also the realm of + * the node's Hub client. The account console accepts the realm (id or name) as a + * `realmId` hint and skips its realm chooser; the node UI origin rides along as `ref`, + * which the account console renders as a back link when the origin is trusted. + * + * Returns undefined when the issuer does not have the Authup realm shape. + */ +export function buildHubConsoleLinks( + issuer: string | undefined, + origin?: string, +): HubConsoleLinks | undefined { + if (!issuer) return undefined; + + let url: URL; + try { + url = new URL(issuer); + } catch { + return undefined; + } + + const match = url.pathname.match(/^(.*)\/realms\/([^/]+)\/?$/); + if (!match) return undefined; + + const base = `${url.origin}${match[1]}`; + const realm = match[2] as string; + + const query = new URLSearchParams({ realmId: decodeURIComponent(realm) }); + if (origin) query.set("ref", origin); + + return { + admin: `${base}/console/admin`, + account: `${base}/console/account?${query.toString()}`, + }; +} diff --git a/test/components/header/AvatarButton.spec.ts b/test/components/header/AvatarButton.spec.ts index d13bd553..39fcb89e 100644 --- a/test/components/header/AvatarButton.spec.ts +++ b/test/components/header/AvatarButton.spec.ts @@ -77,6 +77,22 @@ describe("AvatarButton.vue", () => { } else { throw new Error("Keycloak button not found in avatar menu"); } + + const links = menu.findAll("a"); + const adminConsoleBtn = links.find( + (e1) => e1.text() === "Hub Admin Console", + ); + expect(adminConsoleBtn?.attributes()["href"]).toBe( + "https://auth.fakehub.com/console/admin", + ); + + const accountBtn = links.find((e1) => e1.text() === "Hub Account"); + const accountUrl = new URL(accountBtn?.attributes()["href"] ?? ""); + expect(`${accountUrl.origin}${accountUrl.pathname}`).toBe( + "https://auth.fakehub.com/console/account", + ); + expect(accountUrl.searchParams.get("realmId")).toBe("fakerealm"); + expect(accountUrl.searchParams.get("ref")).toBe(window.location.origin); } it("Unauthenticated avatar menu", async () => { diff --git a/test/mockapi/setup.ts b/test/mockapi/setup.ts index ee71b19b..2d10e4a2 100644 --- a/test/mockapi/setup.ts +++ b/test/mockapi/setup.ts @@ -124,6 +124,8 @@ vi.mock("nuxt/app", () => ({ primevue: vi.fn(), hubAdapterUrl: "https://fakenode.com/api", keycloakBaseUrl: "https://fakenode.com/keycloak/realms/flame", + idpProvider: "hub", + idpIssuer: "https://auth.fakehub.com/realms/fakerealm", auth: { baseURL: "https://fakenode.com/flame/api/auth", disableInternalRouting: false, diff --git a/test/utils/hub-console-url.test.ts b/test/utils/hub-console-url.test.ts new file mode 100644 index 00000000..e74c7c1e --- /dev/null +++ b/test/utils/hub-console-url.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "vitest"; +import { buildHubConsoleLinks } from "~/utils/hub-console-url"; + +describe("buildHubConsoleLinks", () => { + test("derives the console links and realm from the issuer", () => { + expect( + buildHubConsoleLinks( + "https://auth.privateaim.net/realms/my-realm", + "https://node.example.org", + ), + ).toEqual({ + admin: "https://auth.privateaim.net/console/admin", + account: + "https://auth.privateaim.net/console/account?realmId=my-realm&ref=https%3A%2F%2Fnode.example.org", + }); + }); + + test("keeps a sub-path deployment and a trailing slash", () => { + expect( + buildHubConsoleLinks("https://example.org/auth/realms/master/"), + ).toEqual({ + admin: "https://example.org/auth/console/admin", + account: "https://example.org/auth/console/account?realmId=master", + }); + }); + + test.each([undefined, "", "not a url", "https://auth.privateaim.net"])( + "returns undefined for %s", + (issuer) => { + expect(buildHubConsoleLinks(issuer)).toBeUndefined(); + }, + ); +}); From 2ff334718a46aa844b38ab7a3513b2eb78c03501 Mon Sep 17 00:00:00 2001 From: tada5hi Date: Wed, 14 Oct 2026 12:13:21 +0200 Subject: [PATCH 2/4] refactor(header): keep only the account console link --- app/components/header/AvatarButton.vue | 19 +++++---------- app/utils/hub-console-url.ts | 25 ++++++------------- test/components/header/AvatarButton.spec.ts | 12 +++------ test/utils/hub-console-url.test.ts | 27 +++++++++------------ 4 files changed, 28 insertions(+), 55 deletions(-) diff --git a/app/components/header/AvatarButton.vue b/app/components/header/AvatarButton.vue index e1493a89..1200f91a 100644 --- a/app/components/header/AvatarButton.vue +++ b/app/components/header/AvatarButton.vue @@ -4,7 +4,7 @@ import Button from "primevue/button"; import { useRuntimeConfig } from "nuxt/app"; import { useToast } from "primevue/usetoast"; import CleanupDialog from "~/components/header/CleanupDialog.vue"; -import { buildHubConsoleLinks } from "~/utils/hub-console-url"; +import { buildHubAccountUrl } from "~/utils/hub-console-url"; const { signIn, signOut } = useAuth(); const { status: authStatus, data: authData } = useAuthState(); @@ -17,9 +17,9 @@ const config = useRuntimeConfig(); const baseUrl = new URL(config.public.baseUrl as string).origin; const idpProvider = config.public.idpProvider; const victoriaLogsUrl = config.public.victoriaLogsUrl as string; -const hubConsoleLinks = +const hubAccountUrl = idpProvider === "hub" - ? buildHubConsoleLinks( + ? buildHubAccountUrl( config.public.idpIssuer as string | undefined, window.location.origin, ) @@ -50,18 +50,11 @@ const menuItems = computed(() => [ }, }, { - label: "Hub Account", + label: "Account", icon: "pi pi-user-edit", - url: hubConsoleLinks?.account, + url: hubAccountUrl, target: "_blank", - visible: !!hubConsoleLinks, - }, - { - label: "Hub Admin Console", - icon: "pi pi-external-link", - url: hubConsoleLinks?.admin, - target: "_blank", - visible: !!hubConsoleLinks, + visible: !!hubAccountUrl, }, { label: "Node Keycloak Admin", diff --git a/app/utils/hub-console-url.ts b/app/utils/hub-console-url.ts index e42fa9da..4271d9a1 100644 --- a/app/utils/hub-console-url.ts +++ b/app/utils/hub-console-url.ts @@ -1,11 +1,6 @@ -export type HubConsoleLinks = { - admin: string; - account: string; -}; - /** - * Derive the Hub's Authup console links from the OIDC issuer the node UI signs in with, - * e.g. "https://auth.privateaim.net/realms/my-realm". + * Derive the Hub's Authup account console link from the OIDC issuer the node UI signs in + * with, e.g. "https://auth.privateaim.net/realms/my-realm". * * The issuer carries both the Authup base URL and the realm. Authup only lets a user log * in through a client of the user's own realm, so the issuer realm is also the realm of @@ -15,10 +10,10 @@ export type HubConsoleLinks = { * * Returns undefined when the issuer does not have the Authup realm shape. */ -export function buildHubConsoleLinks( +export function buildHubAccountUrl( issuer: string | undefined, origin?: string, -): HubConsoleLinks | undefined { +): string | undefined { if (!issuer) return undefined; let url: URL; @@ -31,14 +26,10 @@ export function buildHubConsoleLinks( const match = url.pathname.match(/^(.*)\/realms\/([^/]+)\/?$/); if (!match) return undefined; - const base = `${url.origin}${match[1]}`; - const realm = match[2] as string; - - const query = new URLSearchParams({ realmId: decodeURIComponent(realm) }); + const query = new URLSearchParams({ + realmId: decodeURIComponent(match[2] as string), + }); if (origin) query.set("ref", origin); - return { - admin: `${base}/console/admin`, - account: `${base}/console/account?${query.toString()}`, - }; + return `${url.origin}${match[1]}/console/account?${query.toString()}`; } diff --git a/test/components/header/AvatarButton.spec.ts b/test/components/header/AvatarButton.spec.ts index 39fcb89e..48fa5adb 100644 --- a/test/components/header/AvatarButton.spec.ts +++ b/test/components/header/AvatarButton.spec.ts @@ -78,15 +78,9 @@ describe("AvatarButton.vue", () => { throw new Error("Keycloak button not found in avatar menu"); } - const links = menu.findAll("a"); - const adminConsoleBtn = links.find( - (e1) => e1.text() === "Hub Admin Console", - ); - expect(adminConsoleBtn?.attributes()["href"]).toBe( - "https://auth.fakehub.com/console/admin", - ); - - const accountBtn = links.find((e1) => e1.text() === "Hub Account"); + const accountBtn = menu + .findAll("a") + .find((e1) => e1.text() === "Account"); const accountUrl = new URL(accountBtn?.attributes()["href"] ?? ""); expect(`${accountUrl.origin}${accountUrl.pathname}`).toBe( "https://auth.fakehub.com/console/account", diff --git a/test/utils/hub-console-url.test.ts b/test/utils/hub-console-url.test.ts index e74c7c1e..c9cf2c6a 100644 --- a/test/utils/hub-console-url.test.ts +++ b/test/utils/hub-console-url.test.ts @@ -1,33 +1,28 @@ import { describe, expect, test } from "vitest"; -import { buildHubConsoleLinks } from "~/utils/hub-console-url"; +import { buildHubAccountUrl } from "~/utils/hub-console-url"; -describe("buildHubConsoleLinks", () => { - test("derives the console links and realm from the issuer", () => { +describe("buildHubAccountUrl", () => { + test("derives the account console link and realm from the issuer", () => { expect( - buildHubConsoleLinks( + buildHubAccountUrl( "https://auth.privateaim.net/realms/my-realm", "https://node.example.org", ), - ).toEqual({ - admin: "https://auth.privateaim.net/console/admin", - account: - "https://auth.privateaim.net/console/account?realmId=my-realm&ref=https%3A%2F%2Fnode.example.org", - }); + ).toBe( + "https://auth.privateaim.net/console/account?realmId=my-realm&ref=https%3A%2F%2Fnode.example.org", + ); }); test("keeps a sub-path deployment and a trailing slash", () => { - expect( - buildHubConsoleLinks("https://example.org/auth/realms/master/"), - ).toEqual({ - admin: "https://example.org/auth/console/admin", - account: "https://example.org/auth/console/account?realmId=master", - }); + expect(buildHubAccountUrl("https://example.org/auth/realms/master/")).toBe( + "https://example.org/auth/console/account?realmId=master", + ); }); test.each([undefined, "", "not a url", "https://auth.privateaim.net"])( "returns undefined for %s", (issuer) => { - expect(buildHubConsoleLinks(issuer)).toBeUndefined(); + expect(buildHubAccountUrl(issuer)).toBeUndefined(); }, ); }); From 47f5eeb79f4f6ba804353326c4a579240bee788f Mon Sep 17 00:00:00 2001 From: tada5hi Date: Wed, 14 Oct 2026 12:13:53 +0200 Subject: [PATCH 3/4] refactor(header): remove the node keycloak admin link --- app/components/header/AvatarButton.vue | 7 ------- test/components/header/AvatarButton.spec.ts | 12 ------------ 2 files changed, 19 deletions(-) diff --git a/app/components/header/AvatarButton.vue b/app/components/header/AvatarButton.vue index 1200f91a..8e4f909b 100644 --- a/app/components/header/AvatarButton.vue +++ b/app/components/header/AvatarButton.vue @@ -14,7 +14,6 @@ const menu = ref(); const showCleanupDialog = ref(false); const config = useRuntimeConfig(); -const baseUrl = new URL(config.public.baseUrl as string).origin; const idpProvider = config.public.idpProvider; const victoriaLogsUrl = config.public.victoriaLogsUrl as string; const hubAccountUrl = @@ -56,12 +55,6 @@ const menuItems = computed(() => [ target: "_blank", visible: !!hubAccountUrl, }, - { - label: "Node Keycloak Admin", - icon: "pi pi-external-link", - url: `${baseUrl}/keycloak/admin`, - target: "_blank", - }, { label: "VictoriaLogs", icon: "pi pi-book", diff --git a/test/components/header/AvatarButton.spec.ts b/test/components/header/AvatarButton.spec.ts index 48fa5adb..196909c8 100644 --- a/test/components/header/AvatarButton.spec.ts +++ b/test/components/header/AvatarButton.spec.ts @@ -64,20 +64,8 @@ describe("AvatarButton.vue", () => { await flushPromises(); // use flushPromises here too for consistency const menu = wrapper.find(".avatar-menu"); - expect(menu.text()).toContain("Keycloak"); expect(menu.text()).toContain(logPrompt); - const keycloakAdminBtn = menu - .findAll("a") - .find((e1) => e1.text() === "Node Keycloak Admin"); - if (keycloakAdminBtn) { - expect(keycloakAdminBtn.attributes()["href"]).toBe( - "https://fakenode.com/keycloak/admin", - ); - } else { - throw new Error("Keycloak button not found in avatar menu"); - } - const accountBtn = menu .findAll("a") .find((e1) => e1.text() === "Account"); From f8aa3d41a4d76786fc64759cb8fecea2fb74bb7d Mon Sep 17 00:00:00 2001 From: tada5hi Date: Thu, 15 Oct 2026 09:23:26 +0200 Subject: [PATCH 4/4] fix(header): treat non-http and malformed-realm issuers as invalid --- app/utils/hub-console-url.ts | 15 +++++++++++---- test/utils/hub-console-url.test.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/utils/hub-console-url.ts b/app/utils/hub-console-url.ts index 4271d9a1..a1851138 100644 --- a/app/utils/hub-console-url.ts +++ b/app/utils/hub-console-url.ts @@ -8,7 +8,7 @@ * `realmId` hint and skips its realm chooser; the node UI origin rides along as `ref`, * which the account console renders as a back link when the origin is trusted. * - * Returns undefined when the issuer does not have the Authup realm shape. + * Returns undefined when the issuer is not an http(s) URL with the Authup realm shape. */ export function buildHubAccountUrl( issuer: string | undefined, @@ -23,12 +23,19 @@ export function buildHubAccountUrl( return undefined; } + if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; + const match = url.pathname.match(/^(.*)\/realms\/([^/]+)\/?$/); if (!match) return undefined; - const query = new URLSearchParams({ - realmId: decodeURIComponent(match[2] as string), - }); + let realm: string; + try { + realm = decodeURIComponent(match[2] as string); + } catch { + return undefined; + } + + const query = new URLSearchParams({ realmId: realm }); if (origin) query.set("ref", origin); return `${url.origin}${match[1]}/console/account?${query.toString()}`; diff --git a/test/utils/hub-console-url.test.ts b/test/utils/hub-console-url.test.ts index c9cf2c6a..25c46b5f 100644 --- a/test/utils/hub-console-url.test.ts +++ b/test/utils/hub-console-url.test.ts @@ -19,7 +19,14 @@ describe("buildHubAccountUrl", () => { ); }); - test.each([undefined, "", "not a url", "https://auth.privateaim.net"])( + test.each([ + undefined, + "", + "not a url", + "https://auth.privateaim.net", + "javascript:/realms/master", + "https://auth.privateaim.net/realms/%ZZ", + ])( "returns undefined for %s", (issuer) => { expect(buildHubAccountUrl(issuer)).toBeUndefined();