diff --git a/app/components/header/AvatarButton.vue b/app/components/header/AvatarButton.vue index 545e3dd3..8e4f909b 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 { buildHubAccountUrl } from "~/utils/hub-console-url"; const { signIn, signOut } = useAuth(); const { status: authStatus, data: authData } = useAuthState(); @@ -13,9 +14,15 @@ 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 = + idpProvider === "hub" + ? buildHubAccountUrl( + config.public.idpIssuer as string | undefined, + window.location.origin, + ) + : undefined; const isAuthenticated = computed(() => authStatus.value === "authenticated"); @@ -42,10 +49,11 @@ const menuItems = computed(() => [ }, }, { - label: "Node Keycloak Admin", - icon: "pi pi-external-link", - url: `${baseUrl}/keycloak/admin`, + label: "Account", + icon: "pi pi-user-edit", + url: hubAccountUrl, target: "_blank", + visible: !!hubAccountUrl, }, { label: "VictoriaLogs", diff --git a/app/utils/hub-console-url.ts b/app/utils/hub-console-url.ts new file mode 100644 index 00000000..a1851138 --- /dev/null +++ b/app/utils/hub-console-url.ts @@ -0,0 +1,42 @@ +/** + * 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 + * 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 is not an http(s) URL with the Authup realm shape. + */ +export function buildHubAccountUrl( + issuer: string | undefined, + origin?: string, +): string | undefined { + if (!issuer) return undefined; + + let url: URL; + try { + url = new URL(issuer); + } catch { + return undefined; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; + + const match = url.pathname.match(/^(.*)\/realms\/([^/]+)\/?$/); + if (!match) return undefined; + + 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/components/header/AvatarButton.spec.ts b/test/components/header/AvatarButton.spec.ts index d13bd553..196909c8 100644 --- a/test/components/header/AvatarButton.spec.ts +++ b/test/components/header/AvatarButton.spec.ts @@ -64,19 +64,17 @@ 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 + const accountBtn = 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"); - } + .find((e1) => e1.text() === "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..25c46b5f --- /dev/null +++ b/test/utils/hub-console-url.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import { buildHubAccountUrl } from "~/utils/hub-console-url"; + +describe("buildHubAccountUrl", () => { + test("derives the account console link and realm from the issuer", () => { + expect( + buildHubAccountUrl( + "https://auth.privateaim.net/realms/my-realm", + "https://node.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(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", + "javascript:/realms/master", + "https://auth.privateaim.net/realms/%ZZ", + ])( + "returns undefined for %s", + (issuer) => { + expect(buildHubAccountUrl(issuer)).toBeUndefined(); + }, + ); +});