Skip to content

feat: show the signed-in user and a Sign out action in the header - #28

Merged
pingsutw merged 8 commits into
mainfrom
feat/sign-out-and-user-name
Aug 5, 2026
Merged

feat: show the signed-in user and a Sign out action in the header#28
pingsutw merged 8 commits into
mainfrom
feat/sign-out-and-user-name

Conversation

@pingsutw

@pingsutw pingsutw commented Aug 4, 2026

Copy link
Copy Markdown
Contributor
Screenshot 2026-08-03 at 6 08 48 PM Screenshot 2026-08-03 at 6 08 44 PM Screenshot 2026-08-03 at 6 08 34 PM

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

  • useIdentityIdentityService.UserInfo via Connect. Errors resolve to null on purpose: an unauthenticated deployment (no proxy in front, trustForwardedIdentityHeaders: false) would otherwise trip AuthStatusProvider'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 (reuses UserIcon / 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 the AWSELBAuthSessionCookie-* shards (ALB splits the session across -0, -1, … as the token grows) and redirects to OIDC_LOGOUT_URL when 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).
  • safeRedirectPath lives in lib/urlUtils.ts, not the route file — App Router rejects non-route exports from route.ts at 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) and src/components/Header/Header.test.tsx (2) — cookie-shard expiry, OIDC_LOGOUT_URL precedence, open-redirect rejection, and the no-identity case. pnpm typecheck clean.

Verified end-to-end on flyte-development (image flyteconsole-v2:signout-29a36b5-amd64), against the live pod:

GET /v2/logout   Cookie: AWSELBAuthSessionCookie-0=…; -1=…; other=1
302 → https://signin.hosted.unionai.cloud/login/signout
set-cookie: AWSELBAuthSessionCookie-0=; Max-Age=0; …
set-cookie: AWSELBAuthSessionCookie-1=; Max-Age=0; …      (`other` untouched)

?redirect_url=//evil.com is ignored.

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 useIdentity hook to fetch IdentityService.UserInfo via Connect and surface identity to the UI.
  • Updated Header to show a user avatar/name with a popover containing a Sign out action.
  • Implemented /v2/logout route to expire AWSELBAuthSessionCookie-* 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.

Comment thread src/components/Header/Header.test.tsx Outdated
Comment on lines +22 to +32
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'),
)
})
Comment thread src/hooks/useIdentity.ts
Comment on lines +20 to +24
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),
Comment thread src/hooks/useIdentity.ts Outdated
Comment thread src/app/logout/route.ts Outdated
Comment thread src/lib/urlUtils.ts Outdated
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>
Copilot AI review requested due to automatic review settings August 4, 2026 00:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() then await 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 use const user = userEvent.setup() and then await 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>
Copilot AI review requested due to automatic review settings August 4, 2026 00:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • useIdentity swallows all userInfo errors and returns null, which makes the query status: 'success'. Because AuthStatusProvider resets the auth-expired state on any successful query whose key isn’t excluded (see AuthStatusProvider’s shouldResetExpiredOnSuccess), 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 calling userEvent.click directly). 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_PATH is duplicated with DEFAULT_REDIRECT_PATH in urlUtils. Since server code (logout route) needs the server-safe value from urlUtils, it’s easy for these to drift over time. Consider sourcing LOGIN_REDIRECT_PATH from DEFAULT_REDIRECT_PATH so 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 calling userEvent.click directly). 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>
Copilot AI review requested due to automatic review settings August 4, 2026 01:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • useIdentity currently converts all userInfo failures into null. 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_PATH duplicates LOGIN_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 -0 suffix). In the "no cookies forwarded" case, the current fallback only expires AWSELBAuthSessionCookie-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 unsharded AWSELBAuthSessionCookie name; 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>
Copilot AI review requested due to automatic review settings August 4, 2026 01:08
pingsutw and others added 2 commits August 3, 2026 18:10
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_PATH duplicates LOGIN_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'

Copilot AI review requested due to automatic review settings August 4, 2026 01:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 forwarded can’t discover additional shards), any deployment producing AWSELBAuthSessionCookie-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>
Copilot AI review requested due to automatic review settings August 4, 2026 05:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • useIdentity currently swallows all errors and returns null. 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 to null and 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 standard MenuButton semantics/keyboard handling for menu items. For simple actions, the codebase typically uses MenuItem with label + 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"

@pingsutw
pingsutw added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit e547093 Aug 5, 2026
4 checks passed
@pingsutw
pingsutw deleted the feat/sign-out-and-user-name branch August 5, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants