Skip to content

Commit b04272d

Browse files
committed
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 <pingsutw@apache.org>
1 parent 83b9f23 commit b04272d

4 files changed

Lines changed: 132 additions & 12 deletions

File tree

src/components/Header/Header.test.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,21 @@ describe('Header', () => {
1919
expect(screen.queryByLabelText('User menu')).not.toBeInTheDocument()
2020
})
2121

22-
it('shows the user name and a Sign out link', async () => {
23-
identity.data = { givenName: 'Kevin', familyName: 'Su', email: '', subject: 'k' }
22+
it('shows the user name and confirms before signing out', async () => {
23+
identity.data = {
24+
givenName: 'Kevin',
25+
familyName: 'Su',
26+
email: '',
27+
subject: 'k',
28+
}
2429
render(<Header />)
2530
expect(screen.getByText('Kevin Su')).toBeInTheDocument()
2631

32+
// The menu item opens the confirmation rather than signing out directly.
2733
await userEvent.click(screen.getByLabelText('User menu'))
28-
expect(screen.getByRole('link', { name: 'Sign out' })).toHaveAttribute(
29-
'href',
30-
expect.stringContaining('/logout'),
31-
)
34+
expect(screen.queryByText('Sign out of Flyte?')).not.toBeInTheDocument()
35+
36+
await userEvent.click(screen.getByRole('button', { name: 'Sign out' }))
37+
expect(screen.getByText('Sign out of Flyte?')).toBeInTheDocument()
3238
})
3339
})

src/components/Header/Header.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
import React, { useMemo, useState } from 'react'
88

99
import { type MenuItem, PopoverMenu } from '@/components/Popovers'
10+
import { SignOutPanel } from '@/components/SignOutPanel'
1011
import { UserIcon } from '@/components/UserIdentityInfo'
1112
import { useIdentity } from '@/hooks/useIdentity'
12-
import { getLogoutUrl } from '@/lib/apiUtils'
1313
import { resolveUserNameFields } from '@/lib/userIdentityUtils'
1414

1515
interface HeaderProps {
@@ -20,20 +20,24 @@ interface HeaderProps {
2020
export function Header({ logoComponent }: HeaderProps) {
2121
const { data: identity } = useIdentity()
2222
const [menuOpen, setMenuOpen] = useState(false)
23+
const [signOutOpen, setSignOutOpen] = useState(false)
2324

2425
const menuItems: MenuItem[] = useMemo(
2526
() => [
2627
{
2728
id: 'logout',
2829
type: 'custom',
2930
component: (
30-
<a
31-
href={getLogoutUrl()}
32-
className="flex w-full cursor-pointer items-center px-4.5 py-2 text-[13px] text-(--system-gray-6)"
33-
onClick={() => setMenuOpen(false)}
31+
<button
32+
type="button"
33+
className="flex w-full cursor-pointer items-center px-4.5 py-2 text-left text-[13px] text-(--system-gray-6)"
34+
onClick={() => {
35+
setMenuOpen(false)
36+
setSignOutOpen(true)
37+
}}
3438
>
3539
Sign out
36-
</a>
40+
</button>
3741
),
3842
},
3943
],
@@ -77,6 +81,8 @@ export function Header({ logoComponent }: HeaderProps) {
7781
</button>
7882
</PopoverMenu>
7983
)}
84+
{/* Outside the menu so closing the popover doesn't unmount the dialog. */}
85+
<SignOutPanel open={signOutOpen} onCancel={() => setSignOutOpen(false)} />
8086
</div>
8187
)
8288
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* © Copyright Union Systems Inc 2026. All rights reserved.
3+
*/
4+
5+
import React from 'react'
6+
import { render, screen } from '@testing-library/react'
7+
import userEvent from '@testing-library/user-event'
8+
import { describe, expect, it, vi } from 'vitest'
9+
10+
import { SignOutPanel } from './SignOutPanel'
11+
12+
const location = vi.hoisted(() => ({ href: '' }))
13+
vi.mock('@/lib/windowUtils', () => ({ getWindow: () => ({ location }) }))
14+
15+
describe('SignOutPanel', () => {
16+
it('renders nothing while closed', () => {
17+
render(<SignOutPanel open={false} onCancel={vi.fn()} />)
18+
expect(screen.queryByText('Sign out of Flyte?')).not.toBeInTheDocument()
19+
})
20+
21+
it('confirms to the logout url and cancels without leaving', async () => {
22+
const onCancel = vi.fn()
23+
render(<SignOutPanel open onCancel={onCancel} />)
24+
expect(screen.getByText('Sign out of Flyte?')).toBeInTheDocument()
25+
26+
await userEvent.click(screen.getByTestId('signout-cancel'))
27+
expect(onCancel).toHaveBeenCalled()
28+
expect(location.href).toBe('')
29+
30+
await userEvent.click(screen.getByTestId('signout-confirm'))
31+
expect(location.href).toBe('/v2/logout?redirect_url=%2Fv2%2Fprojects')
32+
})
33+
})

src/components/SignOutPanel.tsx

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* © Copyright Union Systems Inc 2026. All rights reserved.
3+
*/
4+
'use client'
5+
6+
import { Button } from '@/components/Button'
7+
import {
8+
Dialog,
9+
DialogActions,
10+
DialogBody,
11+
DialogTitle,
12+
} from '@/components/Dialog'
13+
import { Logo } from '@/components/Logo'
14+
import { getLogoutUrl } from '@/lib/apiUtils'
15+
import { getWindow } from '@/lib/windowUtils'
16+
17+
export interface SignOutPanelProps {
18+
open: boolean
19+
onCancel: () => void
20+
}
21+
22+
/**
23+
* Confirmation shown before signing out — matches the session-expired panel.
24+
* Signing out ends the proxy session, so it is worth a deliberate click rather
25+
* than firing on the menu item itself.
26+
*/
27+
export function SignOutPanel({ open, onCancel }: SignOutPanelProps) {
28+
return (
29+
<Dialog
30+
open={open}
31+
onClose={onCancel}
32+
size="lg"
33+
aria-labelledby="signout-dialog-title"
34+
className="border-1 p-10 dark:border-(--system-gray-3)"
35+
>
36+
<div className="flex w-full flex-col items-center gap-1">
37+
<Logo width={55} height={44} className="shrink-0" />
38+
<DialogTitle
39+
id="signout-dialog-title"
40+
className="text-center !text-2xl font-semibold"
41+
>
42+
Sign out of Flyte?
43+
</DialogTitle>
44+
<DialogBody className="!mt-2 w-full !p-0">
45+
<p className="text-center text-sm font-semibold text-zinc-500 dark:text-zinc-400">
46+
You will need to sign in again to continue.
47+
</p>
48+
</DialogBody>
49+
<DialogActions className="flex sm:flex-col">
50+
<Button
51+
color="union"
52+
size="lg"
53+
className="!w-[311px] justify-center"
54+
data-testid="signout-confirm"
55+
onClick={() => {
56+
const w = getWindow()
57+
if (w) w.location.href = getLogoutUrl()
58+
}}
59+
>
60+
Sign out
61+
</Button>
62+
<Button
63+
outline
64+
size="lg"
65+
className="!w-[311px] justify-center"
66+
data-testid="signout-cancel"
67+
onClick={onCancel}
68+
>
69+
Cancel
70+
</Button>
71+
</DialogActions>
72+
</div>
73+
</Dialog>
74+
)
75+
}

0 commit comments

Comments
 (0)