feat: show the signed-in user and a Sign out action in the header - #28
Conversation
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 <pingsutw@apache.org>
There was a problem hiding this comment.
Pull request overview
Adds user identity visibility and a sign-out capability to the OSS console header by introducing an identity hook, a header user menu, and a server-side logout endpoint that clears ALB OIDC session cookie shards and redirects safely.
Changes:
- Added
useIdentityhook to fetchIdentityService.UserInfovia Connect and surface identity to the UI. - Updated
Headerto show a user avatar/name with a popover containing a Sign out action. - Implemented
/v2/logoutroute to expireAWSELBAuthSessionCookie-*shards and perform safe post-logout redirects (plus unit tests).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/urlUtils.ts | Adds safeRedirectPath and a default redirect path for logout flows. |
| src/lib/apiUtils.ts | Adds getLogoutUrl() helper for generating the logout link. |
| src/hooks/useIdentity.ts | Introduces identity query hook for fetching current user info. |
| src/components/Header/Header.tsx | Renders avatar/name and a popover menu with a sign-out link when identity is present. |
| src/components/Header/Header.test.tsx | Tests header behavior for identity/no-identity and presence of sign-out link. |
| src/app/logout/route.ts | Adds logout route to clear ALB session cookie shards and redirect. |
| src/app/logout/route.test.ts | Tests cookie shard expiry, IdP logout precedence, and redirect_url sanitization. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| it('shows the user name and a Sign out link', async () => { | ||
| identity.data = { givenName: 'Kevin', familyName: 'Su', email: '', subject: 'k' } | ||
| render(<Header />) | ||
| 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'), | ||
| ) | ||
| }) |
| 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), |
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 <pingsutw@apache.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/hooks/useIdentity.ts:23
- The inline comment includes the unexplained prefix "ponytail:". Since this is new code and the prefix isn’t used elsewhere in the repo, it reads like internal jargon and makes the rationale less clear.
// 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.
src/app/logout/route.ts:26
- The docstring uses the unexplained prefix "ponytail:". Since this file is part of the public app surface area, it would be clearer to remove the prefix and keep the rationale in plain language.
* 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.
src/components/Header/Header.test.tsx:38
- This test uses
userEvent.click(...)directly instead of the project’s existing pattern (const user = userEvent.setup()thenawait user.click(...)). The setup-based API is already used in the repo and is the recommended way to avoid async/timer edge cases with @testing-library/user-event.
await userEvent.click(screen.getByLabelText('User menu'))
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()
src/components/SignOutPanel.test.tsx:32
- This test calls
userEvent.click(...)directly. Elsewhere in the repo tests useconst user = userEvent.setup()and thenawait user.click(...), which is the established pattern and recommended API for reliable async behavior.
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')
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 <pingsutw@apache.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/hooks/useIdentity.ts:26
useIdentityswallows alluserInfoerrors and returnsnull, which makes the querystatus: 'success'. BecauseAuthStatusProviderresets the auth-expired state on any successful query whose key isn’t excluded (seeAuthStatusProvider’sshouldResetExpiredOnSuccess), the['identity']query can clear/loop the login-panel flow after an auth-expired error elsewhere, potentially preventing the user from ever seeing the session-expired UI.
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,
src/components/Header/Header.test.tsx:38
- Tests elsewhere in the repo use
userEvent.setup()(instead of callinguserEvent.clickdirectly). Using a per-test user instance avoids subtle async/timer and pointer-event issues and keeps these tests consistent with the existing pattern.
it('shows the user name and confirms before signing out', async () => {
identity.data = {
givenName: 'Kevin',
familyName: 'Su',
email: '',
subject: 'k',
}
render(<Header />)
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.queryByText('Sign out of Flyte?')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Sign out' }))
expect(screen.getByText('Sign out of Flyte?')).toBeInTheDocument()
src/lib/apiUtils.ts:30
LOGIN_REDIRECT_PATHis duplicated withDEFAULT_REDIRECT_PATHinurlUtils. Since server code (logout route) needs the server-safe value fromurlUtils, it’s easy for these to drift over time. Consider sourcingLOGIN_REDIRECT_PATHfromDEFAULT_REDIRECT_PATHso there’s a single canonical 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)}`
}
src/components/SignOutPanel.test.tsx:32
- Tests elsewhere in the repo use
userEvent.setup()(instead of callinguserEvent.clickdirectly). Using a per-test user instance avoids subtle async/timer and pointer-event issues and keeps these tests consistent with the existing pattern.
it('confirms to the logout url and cancels without leaving', async () => {
const onCancel = vi.fn()
render(<SignOutPanel open onCancel={onCancel} />)
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')
Clearing only the cookies the request carried cleared nothing when the load balancer doesn't forward its own session cookie to the target, so sign out left the session intact. Expire the usual shards regardless, and log what the request did carry. Signed-off-by: Kevin Su <pingsutw@apache.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (6)
src/app/logout/route.ts:35
- The "ponytail:" prefix in this public comment is unclear and makes it harder to understand the rationale when scanning the code. Consider using a standard prefix like "Note:" or "Rationale:".
* 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.
src/hooks/useIdentity.ts:24
useIdentitycurrently converts alluserInfofailures intonull. That avoids triggering AuthStatusProvider on Unauthenticated (good), but it also masks real problems (network/5xx/schema) by silently removing the identity UI. Consider swallowing only auth/Unauthenticated errors and rethrowing everything else.
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),
src/lib/urlUtils.ts:128
DEFAULT_REDIRECT_PATHduplicatesLOGIN_REDIRECT_PATH(and the comment says they should mirror). Keeping two separate constants risks them drifting out of sync over time; consider moving this path to a single shared source of truth that both login/logout code paths import.
/** Post-login/logout landing path. Mirrors apiUtils' `LOGIN_REDIRECT_PATH`. */
export const DEFAULT_REDIRECT_PATH = '/v2/projects'
src/app/logout/route.ts:53
- This route unconditionally logs on every logout request. Even though this only logs cookie names, it will add noise to server logs and could leak implementation details; consider gating it behind a non-production / debug flag (or removing it once validated).
// Logged because whether the proxy forwards its session cookie decides whether
// the request-derived names above are ever non-empty.
console.log('[logout] proxy session cookies on request:', forwarded)
src/app/logout/route.ts:61
- When the ALB session cookie is not sharded, the cookie name is typically
AWSELBAuthSessionCookie(no-0suffix). In the "no cookies forwarded" case, the current fallback only expiresAWSELBAuthSessionCookie-0..-3, so an unsharded session cookie would remain and the user may not actually be logged out.
const names = new Set([
...forwarded,
...Array.from(
{ length: ALB_SESSION_COOKIE_SHARDS },
(_, i) => `${ALB_SESSION_COOKIE_PREFIX}-${i}`,
src/app/logout/route.test.ts:60
- The logout tests currently only validate sharded cookie deletion (
AWSELBAuthSessionCookie-0,-1, …). ALB can also use an unshardedAWSELBAuthSessionCookiename; adding an assertion for that case will prevent regressions in logout behavior.
it('expires the shards even when the proxy forwards no cookies', async () => {
const res = await GET(new Request('https://flyte.example/v2/logout'))
const setCookie = res.headers.getSetCookie()
expect(setCookie).toHaveLength(4)
expect(setCookie[0]).toContain('AWSELBAuthSessionCookie-0=;')
})
ALB confirmed not to forward its session cookie to the target; the finding is in the comment now, so the per-request log is just noise. Signed-off-by: Kevin Su <pingsutw@apache.org>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Kevin Su <pingsutw@gmail.com>
The expired cookie names were hardcoded to AWS ALB's shards, which is the one proxy that needs them: it has no logout endpoint of its own. Proxies that do (oauth2-proxy, GCP IAP, Cloudflare Access) can point OIDC_LOGOUT_URL at it and set LOGOUT_CLEAR_COOKIES empty. Signed-off-by: Kevin Su <pingsutw@apache.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/hooks/useIdentity.ts:23
- The comment prefix "ponytail:" is unexplained and appears to be internal jargon; it reduces clarity for future maintainers. Consider rewriting it as a plain explanatory note.
// Note: 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.
src/app/logout/route.ts:35
- The doc comment uses the unexplained prefix "ponytail:", which makes the guidance harder to scan/search. Rephrase as a normal note so the intent is clear to readers unfamiliar with this term.
/** Proxy session cookies are host-only on `/`; deletion must match to overwrite. */
const EXPIRED = 'Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=Lax'
src/lib/urlUtils.ts:127
DEFAULT_REDIRECT_PATHduplicatesLOGIN_REDIRECT_PATH(src/lib/apiUtils.ts:16). Because apiUtils.ts is a client module ('use client'), this duplication is easy to accidentally diverge over time. Consider moving the shared redirect-path constant into a non-client module (e.g. urlUtils.ts or a new lib/redirectPaths.ts) and importing it from both apiUtils.ts and the logout route.
/** Post-login/logout landing path. Keep in sync with apiUtils.LOGIN_REDIRECT_PATH (apiUtils is client-only). */
export const DEFAULT_REDIRECT_PATH = '/v2/projects'
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/lib/urlUtils.ts:127
- DEFAULT_REDIRECT_PATH duplicates apiUtils.LOGIN_REDIRECT_PATH (both currently '/v2/projects'). Even with the “keep in sync” note, this is easy to accidentally diverge and would change the post-login vs post-logout landing behavior inconsistently. Consider exporting a single shared constant (e.g. from urlUtils or a small shared module) and importing it from both the client (apiUtils) and server (logout route) code paths.
/** Post-login/logout landing path. Keep in sync with apiUtils.LOGIN_REDIRECT_PATH (apiUtils is client-only). */
export const DEFAULT_REDIRECT_PATH = '/v2/projects'
src/app/logout/route.ts:29
- The default CLEAR_COOKIES only includes shards -0 through -3. Since the comment notes ALB shards grow “as the token grows” and (per the same comment) ALB does not forward its cookies (so
forwardedcan’t discover additional shards), any deployment producingAWSELBAuthSessionCookie-4+ would remain partially signed-in after /v2/logout. Consider expanding the default shard range (e.g. -0..-9 or higher) to make logout robust for larger sessions, while keeping LOGOUT_CLEAR_COOKIES as the override.
const CLEAR_COOKIES = (
process.env.LOGOUT_CLEAR_COOKIES ??
'AWSELBAuthSessionCookie-0,AWSELBAuthSessionCookie-1,AWSELBAuthSessionCookie-2,AWSELBAuthSessionCookie-3'
)
4K per shard, 16K total, so -0..-3 covers every case AWS can produce. Signed-off-by: Kevin Su <pingsutw@apache.org>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/hooks/useIdentity.ts:24
useIdentitycurrently swallows all errors and returnsnull. That turns network/server failures into a successful query result, disables react-query retries, and makes real outages indistinguishable from the intended "no auth" Unauthenticated case. Consider only converting auth/Unauthenticated errors tonulland rethrowing everything else so non-auth failures can still surface/retry without triggering AuthStatusProvider’s auth-expired flow.
queryKey: ['identity'],
// Note: 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),
src/components/Header/Header.tsx:45
- The Sign out entry is implemented as a
type: 'custom'menu item containing a raw<button>, which bypasses PopoverMenu’s standardMenuButtonsemantics/keyboard handling for menu items. For simple actions, the codebase typically usesMenuItemwithlabel+onClick(e.g.src/components/pages/ListApps/table/ListAppsOverflowActions.tsx:36-69). Switching to a normal menu item improves consistency and accessibility and also avoids manually closing the menu.
const menuItems: MenuItem[] = useMemo(
() => [
{
id: 'logout',
type: 'custom',
component: (
<button
type="button"
Why
The header shows no identity at all: you can't tell which account the console is using, and there's no way to sign out. Cloud has this (avatar → Profile / Theme / Sign out); OSS only needs the identity + sign out half.
What
useIdentity—IdentityService.UserInfovia Connect. Errors resolve tonullon purpose: an unauthenticated deployment (no proxy in front,trustForwardedIdentityHeaders: false) would otherwise tripAuthStatusProvider's refresh/login-panel flow, and "this deployment has no auth" is not an expired session. With no identity, the menu doesn't render.Header— avatar + user name (reusesUserIcon/resolveUserNameFields), popover with a single Sign out entry. No Profile or Theme: cloud-only surfaces.src/app/logout/route.ts(/v2/logout) — the proxy authenticates but has no logout endpoint, so this app serves one. It expires theAWSELBAuthSessionCookie-*shards (ALB splits the session across-0,-1, … as the token grows) and redirects toOIDC_LOGOUT_URLwhen set, else a validated same-origin path.The shards are expired unconditionally rather than derived from the request: ALB does not forward its own session cookie to the target, so expiring only what arrived cleared nothing and left the user signed in. Verified on
flyte-development— the route logged an empty cookie list on every real sign out.SignOutPanel— a confirmation dialog built from the same pieces as the session-expired panel, so a stray click on the menu item can't end the session. Mirrors what v1 shows (feat: confirm before signing out #minor flyteorg/flyteconsole#937).safeRedirectPathlives inlib/urlUtils.ts, not the route file — App Router rejects non-route exports fromroute.tsat build time.Without
OIDC_LOGOUT_URL, clearing the cookie only ends the proxy session; if the IdP session is still live the next request re-authenticates silently. Chart support for setting it: flyteorg/flyte#7759.Tests
src/app/logout/route.test.ts(5) andsrc/components/Header/Header.test.tsx(2) — cookie-shard expiry,OIDC_LOGOUT_URLprecedence, open-redirect rejection, and the no-identity case.pnpm typecheckclean.Verified end-to-end on
flyte-development(imageflyteconsole-v2:signout-29a36b5-amd64), against the live pod:?redirect_url=//evil.comis ignored.