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 (
+
+ )
+}
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. */}
+