From 83b9f2317de77b157562358d9907b49d3ad90a55 Mon Sep 17 00:00:00 2001 From: Kevin Su Date: Mon, 3 Aug 2026 17:07:39 -0700 Subject: [PATCH 1/8] feat: show the signed-in user and a Sign out action in the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header had no identity affordance at all — you could not tell which account the console was using, or sign out of it. Adds a `useIdentity` hook over `IdentityService.UserInfo` and renders the avatar + name in the header, with a popover carrying a single "Sign out" entry (no Profile/Theme — those are cloud-only surfaces). Sign out points at a new `/v2/logout` route handler rather than the API, because the proxy in front of Flyte authenticates but has no logout endpoint: the route expires the ALB OIDC session cookie shards and then redirects to `OIDC_LOGOUT_URL` (the IdP's logout endpoint) when set, or to a validated same-origin path otherwise. Signed-off-by: Kevin Su --- src/app/logout/route.test.ts | 67 +++++++++++++++++++++++++++ src/app/logout/route.ts | 48 +++++++++++++++++++ src/components/Header/Header.test.tsx | 33 +++++++++++++ src/components/Header/Header.tsx | 62 ++++++++++++++++++++++++- src/hooks/useIdentity.ts | 28 +++++++++++ src/lib/apiUtils.ts | 8 ++++ src/lib/urlUtils.ts | 15 ++++++ 7 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 src/app/logout/route.test.ts create mode 100644 src/app/logout/route.ts create mode 100644 src/components/Header/Header.test.tsx create mode 100644 src/hooks/useIdentity.ts diff --git a/src/app/logout/route.test.ts b/src/app/logout/route.test.ts new file mode 100644 index 0000000..ce528db --- /dev/null +++ b/src/app/logout/route.test.ts @@ -0,0 +1,67 @@ +/** + * © Copyright Union Systems Inc 2026. All rights reserved. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { safeRedirectPath } from '@/lib/urlUtils' + +import { GET } from './route' + +const cookieJar = vi.hoisted(() => ({ names: [] as string[] })) +vi.mock('next/headers', () => ({ + cookies: async () => ({ + getAll: () => cookieJar.names.map((name) => ({ name, value: 'x' })), + }), +})) + +describe('safeRedirectPath', () => { + it('keeps same-origin absolute paths', () => { + expect(safeRedirectPath('/v2/projects/foo')).toBe('/v2/projects/foo') + }) + + it('rejects off-site redirects', () => { + for (const bad of ['//evil.com', 'https://evil.com', '/\\evil.com', null]) { + expect(safeRedirectPath(bad)).toBe('/v2/projects') + } + }) +}) + +describe('GET /v2/logout', () => { + beforeEach(() => { + cookieJar.names = [] + delete process.env.OIDC_LOGOUT_URL + }) + + it('expires every ALB session cookie shard and redirects', async () => { + cookieJar.names = [ + 'AWSELBAuthSessionCookie-0', + 'AWSELBAuthSessionCookie-1', + 'unrelated', + ] + const res = await GET(new Request('https://flyte.example/v2/logout')) + + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('https://flyte.example/v2/projects') + + const setCookie = res.headers.getSetCookie() + expect(setCookie).toHaveLength(2) + expect(setCookie.every((c) => c.includes('Max-Age=0'))).toBe(true) + expect(setCookie.some((c) => c.includes('unrelated'))).toBe(false) + }) + + it('redirects to the IdP when OIDC_LOGOUT_URL is set', async () => { + process.env.OIDC_LOGOUT_URL = 'https://okta.example/oauth2/v1/logout' + const res = await GET(new Request('https://flyte.example/v2/logout')) + expect(res.headers.get('location')).toBe( + 'https://okta.example/oauth2/v1/logout', + ) + }) + + it('ignores an attacker-supplied redirect_url', async () => { + const res = await GET( + new Request('https://flyte.example/v2/logout?redirect_url=//evil.com'), + ) + expect(res.headers.get('location')).toBe('https://flyte.example/v2/projects') + }) +}) diff --git a/src/app/logout/route.ts b/src/app/logout/route.ts new file mode 100644 index 0000000..584b87e --- /dev/null +++ b/src/app/logout/route.ts @@ -0,0 +1,48 @@ +/** + * © Copyright Union Systems Inc 2026. All rights reserved. + */ + +import { cookies } from 'next/headers' +import { NextResponse } from 'next/server' + +import { safeRedirectPath } from '@/lib/urlUtils' + +export const dynamic = 'force-dynamic' + +/** + * ALB shards the OIDC session across `AWSELBAuthSessionCookie-0`, `-1`, … as the + * token grows, so we expire every cookie with this prefix rather than a fixed pair. + */ +const ALB_SESSION_COOKIE_PREFIX = 'AWSELBAuthSessionCookie' + +/** ALB sets its session cookies host-only on `/`; deletion must match. */ +const EXPIRED = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax' + +/** + * Signs the user out by expiring the ALB OIDC session cookies. + * + * ponytail: clearing the cookie only ends the *ALB* session. If the IdP session is + * still live, the next request re-authenticates silently — set `OIDC_LOGOUT_URL` + * (e.g. the Okta `/v1/logout` endpoint) to end that one too. + */ +export async function GET(request: Request) { + const url = new URL(request.url) + const target = + process.env.OIDC_LOGOUT_URL || + new URL(safeRedirectPath(url.searchParams.get('redirect_url')), url.origin) + .toString() + + const response = NextResponse.redirect(target, 302) + + const names = (await cookies()) + .getAll() + .map((c) => c.name) + .filter((name) => name.startsWith(ALB_SESSION_COOKIE_PREFIX)) + + // Nothing to clear (no ALB in front, or already signed out) — still redirect. + for (const name of names) { + response.headers.append('set-cookie', `${name}=; ${EXPIRED}`) + } + + return response +} diff --git a/src/components/Header/Header.test.tsx b/src/components/Header/Header.test.tsx new file mode 100644 index 0000000..283e82e --- /dev/null +++ b/src/components/Header/Header.test.tsx @@ -0,0 +1,33 @@ +/** + * © Copyright Union Systems Inc 2026. All rights reserved. + */ + +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { Header } from './Header' + +const identity = vi.hoisted(() => ({ data: undefined as unknown })) +vi.mock('@/hooks/useIdentity', () => ({ useIdentity: () => identity })) + +describe('Header', () => { + it('renders no user menu when there is no identity', () => { + identity.data = undefined + render(
) + expect(screen.queryByLabelText('User menu')).not.toBeInTheDocument() + }) + + it('shows the user name and a Sign out link', async () => { + identity.data = { givenName: 'Kevin', familyName: 'Su', email: '', subject: 'k' } + render(
) + expect(screen.getByText('Kevin Su')).toBeInTheDocument() + + await userEvent.click(screen.getByLabelText('User menu')) + expect(screen.getByRole('link', { name: 'Sign out' })).toHaveAttribute( + 'href', + expect.stringContaining('/logout'), + ) + }) +}) diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 6722ae9..6870840 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -4,7 +4,13 @@ 'use client' -import React from 'react' +import React, { useMemo, useState } from 'react' + +import { type MenuItem, PopoverMenu } from '@/components/Popovers' +import { UserIcon } from '@/components/UserIdentityInfo' +import { useIdentity } from '@/hooks/useIdentity' +import { getLogoutUrl } from '@/lib/apiUtils' +import { resolveUserNameFields } from '@/lib/userIdentityUtils' interface HeaderProps { logoComponent?: React.ReactNode @@ -12,11 +18,65 @@ interface HeaderProps { } export function Header({ logoComponent }: HeaderProps) { + const { data: identity } = useIdentity() + const [menuOpen, setMenuOpen] = useState(false) + + const menuItems: MenuItem[] = useMemo( + () => [ + { + id: 'logout', + type: 'custom', + component: ( + setMenuOpen(false)} + > + Sign out + + ), + }, + ], + [], + ) + + const { firstName, lastName, userIdentityString } = resolveUserNameFields( + identity?.givenName, + identity?.familyName, + identity?.email, + identity?.subject, + ) + return (
{logoComponent ? logoComponent :
} + {/* No identity — nothing is authenticating this deployment, so no menu. */} + {identity && ( + + + + )}
) } diff --git a/src/hooks/useIdentity.ts b/src/hooks/useIdentity.ts new file mode 100644 index 0000000..07bb937 --- /dev/null +++ b/src/hooks/useIdentity.ts @@ -0,0 +1,28 @@ +/** + * © Copyright Union Systems Inc 2026. All rights reserved. + */ + +import { useQuery } from '@tanstack/react-query' + +import { IdentityService } from '@/gen/flyteidl2/auth/identity_pb' +import { isBrowser } from '@/lib/windowUtils' + +import { useConnectRpcClient } from './useConnectRpc' + +/** + * Current user, or null when the deployment has no auth in front of it + * (UserInfo answers Unauthenticated when identity headers aren't trusted). + */ +export function useIdentity() { + const client = useConnectRpcClient(IdentityService) + + return useQuery({ + queryKey: ['identity'], + // ponytail: swallow the error instead of letting it reach the query cache — + // an errored query trips AuthStatusProvider's refresh/login-panel flow, and + // "this deployment has no auth" is not an expired session. + queryFn: () => client.userInfo({}).catch(() => null), + enabled: isBrowser(), + refetchInterval: 1000 * 60, + }) +} diff --git a/src/lib/apiUtils.ts b/src/lib/apiUtils.ts index e0a0696..c5650ad 100644 --- a/src/lib/apiUtils.ts +++ b/src/lib/apiUtils.ts @@ -21,6 +21,14 @@ export function getLoginUrl(): string { return `${baseUrl || ''}/login?redirect_url=${encodeURIComponent(LOGIN_REDIRECT_PATH)}` } +/** + * Logout URL. Unlike login, this is served by this app (`src/app/logout/route.ts`, + * under the `/v2` basePath) — the proxy authenticates but has no logout endpoint. + */ +export function getLogoutUrl(): string { + return `/v2/logout?redirect_url=${encodeURIComponent(LOGIN_REDIRECT_PATH)}` +} + export function createTransport(_useBinaryFormat: boolean) { return createConnectTransport({ baseUrl: BASE_ADMIN_API, diff --git a/src/lib/urlUtils.ts b/src/lib/urlUtils.ts index 62f5684..43c05c8 100644 --- a/src/lib/urlUtils.ts +++ b/src/lib/urlUtils.ts @@ -122,3 +122,18 @@ export function getS3ConsoleUrl( const encodedKey = encodeURIComponent(keyPart) return `${base}/s3/object/${encodedBucket}?prefix=${encodedKey}` } + +/** Post-login/logout landing path. Mirrors apiUtils' `LOGIN_REDIRECT_PATH`. */ +export const DEFAULT_REDIRECT_PATH = '/v2/projects' + +/** + * Only same-origin absolute paths may be used as a post-logout redirect — + * the value comes off the query string, so anything else is an open redirect. + */ +export function safeRedirectPath(raw: string | null): string { + if (!raw) return DEFAULT_REDIRECT_PATH + if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) { + return DEFAULT_REDIRECT_PATH + } + return raw +} From b04272ddc444567b44abe265e21fadb2ba1f3861 Mon Sep 17 00:00:00 2001 From: Kevin Su Date: Mon, 3 Aug 2026 17:32:57 -0700 Subject: [PATCH 2/8] feat: confirm before signing out Sign out fired on the menu item itself, so a stray click on a small entry ended the session with no way back other than signing in again. Adds a confirmation dialog built from the same pieces as the session-expired panel, matching what v1 shows (flyteorg/flyteconsole#937). Signed-off-by: Kevin Su --- src/components/Header/Header.test.tsx | 18 ++++--- src/components/Header/Header.tsx | 18 ++++--- src/components/SignOutPanel.test.tsx | 33 ++++++++++++ src/components/SignOutPanel.tsx | 75 +++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 src/components/SignOutPanel.test.tsx create mode 100644 src/components/SignOutPanel.tsx diff --git a/src/components/Header/Header.test.tsx b/src/components/Header/Header.test.tsx index 283e82e..533f303 100644 --- a/src/components/Header/Header.test.tsx +++ b/src/components/Header/Header.test.tsx @@ -19,15 +19,21 @@ describe('Header', () => { expect(screen.queryByLabelText('User menu')).not.toBeInTheDocument() }) - it('shows the user name and a Sign out link', async () => { - identity.data = { givenName: 'Kevin', familyName: 'Su', email: '', subject: 'k' } + it('shows the user name and confirms before signing out', async () => { + identity.data = { + givenName: 'Kevin', + familyName: 'Su', + email: '', + subject: 'k', + } render(
) expect(screen.getByText('Kevin Su')).toBeInTheDocument() + // The menu item opens the confirmation rather than signing out directly. await userEvent.click(screen.getByLabelText('User menu')) - expect(screen.getByRole('link', { name: 'Sign out' })).toHaveAttribute( - 'href', - expect.stringContaining('/logout'), - ) + expect(screen.queryByText('Sign out of Flyte?')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Sign out' })) + expect(screen.getByText('Sign out of Flyte?')).toBeInTheDocument() }) }) diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 6870840..d48cc90 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -7,9 +7,9 @@ import React, { useMemo, useState } from 'react' import { type MenuItem, PopoverMenu } from '@/components/Popovers' +import { SignOutPanel } from '@/components/SignOutPanel' import { UserIcon } from '@/components/UserIdentityInfo' import { useIdentity } from '@/hooks/useIdentity' -import { getLogoutUrl } from '@/lib/apiUtils' import { resolveUserNameFields } from '@/lib/userIdentityUtils' interface HeaderProps { @@ -20,6 +20,7 @@ interface HeaderProps { export function Header({ logoComponent }: HeaderProps) { const { data: identity } = useIdentity() const [menuOpen, setMenuOpen] = useState(false) + const [signOutOpen, setSignOutOpen] = useState(false) const menuItems: MenuItem[] = useMemo( () => [ @@ -27,13 +28,16 @@ export function Header({ logoComponent }: HeaderProps) { id: 'logout', type: 'custom', component: ( - setMenuOpen(false)} + ), }, ], @@ -77,6 +81,8 @@ export function Header({ logoComponent }: HeaderProps) { )} + {/* Outside the menu so closing the popover doesn't unmount the dialog. */} + setSignOutOpen(false)} />
) } diff --git a/src/components/SignOutPanel.test.tsx b/src/components/SignOutPanel.test.tsx new file mode 100644 index 0000000..4f11c3c --- /dev/null +++ b/src/components/SignOutPanel.test.tsx @@ -0,0 +1,33 @@ +/** + * © Copyright Union Systems Inc 2026. All rights reserved. + */ + +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { SignOutPanel } from './SignOutPanel' + +const location = vi.hoisted(() => ({ href: '' })) +vi.mock('@/lib/windowUtils', () => ({ getWindow: () => ({ location }) })) + +describe('SignOutPanel', () => { + it('renders nothing while closed', () => { + render() + expect(screen.queryByText('Sign out of Flyte?')).not.toBeInTheDocument() + }) + + it('confirms to the logout url and cancels without leaving', async () => { + const onCancel = vi.fn() + render() + expect(screen.getByText('Sign out of Flyte?')).toBeInTheDocument() + + await userEvent.click(screen.getByTestId('signout-cancel')) + expect(onCancel).toHaveBeenCalled() + expect(location.href).toBe('') + + await userEvent.click(screen.getByTestId('signout-confirm')) + expect(location.href).toBe('/v2/logout?redirect_url=%2Fv2%2Fprojects') + }) +}) diff --git a/src/components/SignOutPanel.tsx b/src/components/SignOutPanel.tsx new file mode 100644 index 0000000..da2d9ef --- /dev/null +++ b/src/components/SignOutPanel.tsx @@ -0,0 +1,75 @@ +/** + * © Copyright Union Systems Inc 2026. All rights reserved. + */ +'use client' + +import { Button } from '@/components/Button' +import { + Dialog, + DialogActions, + DialogBody, + DialogTitle, +} from '@/components/Dialog' +import { Logo } from '@/components/Logo' +import { getLogoutUrl } from '@/lib/apiUtils' +import { getWindow } from '@/lib/windowUtils' + +export interface SignOutPanelProps { + open: boolean + onCancel: () => void +} + +/** + * Confirmation shown before signing out — matches the session-expired panel. + * Signing out ends the proxy session, so it is worth a deliberate click rather + * than firing on the menu item itself. + */ +export function SignOutPanel({ open, onCancel }: SignOutPanelProps) { + return ( + +
+ + + Sign out of Flyte? + + +

+ You will need to sign in again to continue. +

+
+ + + + +
+
+ ) +} From 752dd76de6641762a615d2226103ed69d5b287f0 Mon Sep 17 00:00:00 2001 From: Kevin Su Date: Mon, 3 Aug 2026 17:38:24 -0700 Subject: [PATCH 3/8] fix: stack the sign-out dialog buttons so they fit the modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DialogActions goes to a row at sm+, and the buttons carried the fixed 311px width copied from the single-button login panel — side by side that overflowed the dialog. Signed-off-by: Kevin Su --- src/components/SignOutPanel.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/SignOutPanel.tsx b/src/components/SignOutPanel.tsx index da2d9ef..1a04360 100644 --- a/src/components/SignOutPanel.tsx +++ b/src/components/SignOutPanel.tsx @@ -46,11 +46,13 @@ export function SignOutPanel({ open, onCancel }: SignOutPanelProps) { You will need to sign in again to continue.

- + {/* !flex-col: DialogActions defaults to a row at sm+, which would sit the + two buttons side by side and overflow the dialog. */} +