Skip to content

Commit 8c2249b

Browse files
authored
fix(auth): speed up sign-out and show pending state on logout (#378)
## Why Clicking "Log out" appeared to do nothing for several seconds: server actions POST to the current page URL, so the browser sat waiting on a request to e.g. `/dashboard/<team>/sandboxes/monitoring?...` while `completeOrySignOut` ran 4 sequential network round-trips (Auth.js sign-out, Hydra consent + login revocation, Kratos session deletion) before returning the redirect — with no pending UI. ## What - Run the independent Hydra OAuth and Kratos revocations concurrently in `completeOrySignOut`, and the two Hydra calls (consent + login) concurrently as well — ~3 sequential round-trips collapse to the slowest one; all helpers already log-and-swallow errors and Kratos retries 429 contention. - Invoke the sign-out action inside a sync `useTransition` callback (`void signOutAction()`, not awaited — the action redirects via NEXT_REDIRECT and awaiting it surfaces noisy aborted-action errors). - Close the dropdown on logout click and show a fullscreen "Logging out..." overlay until the redirect lands, portaled to `document.body` so the sidebar's `fixed z-20` stacking context can't trap it under the sticky dashboard header (z-50).
1 parent 8da8dce commit 8c2249b

5 files changed

Lines changed: 35 additions & 10 deletions

File tree

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@radix-ui/react-dropdown-menu": "^2.1.7",
6868
"@radix-ui/react-label": "^2.1.3",
6969
"@radix-ui/react-popover": "^1.1.15",
70+
"@radix-ui/react-portal": "^1.1.9",
7071
"@radix-ui/react-radio-group": "^1.3.8",
7172
"@radix-ui/react-scroll-area": "^1.2.4",
7273
"@radix-ui/react-select": "^2.1.7",

src/core/server/auth/ory/oauth-session.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ export async function revokeOryOAuthSessionsForSubject(
1818
return
1919
}
2020

21-
await revokeConsentSessions(subject, clientId)
22-
await revokeLoginSessions(subject)
21+
// Independent Hydra revocations; run concurrently. Each call logs and
22+
// swallows its own errors.
23+
await Promise.all([
24+
revokeConsentSessions(subject, clientId),
25+
revokeLoginSessions(subject),
26+
])
2327
}
2428

2529
async function revokeConsentSessions(

src/core/server/auth/ory/signout-flow.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,14 @@ export async function completeOrySignOut(origin = BASE_URL): Promise<string> {
4444
)
4545
}
4646

47-
if (userId) {
48-
await revokeOryOAuthSessionsForSubject(userId)
49-
}
50-
51-
if (identityId) {
52-
await revokeKratosSessionsForIdentity(identityId)
53-
}
47+
// Hydra OAuth and Kratos session revocations are independent admin calls;
48+
// run them concurrently to keep the sign-out action fast. Both helpers
49+
// log-and-swallow their own errors, and the Kratos helper retries 429
50+
// contention, so Promise.all never rejects here.
51+
await Promise.all([
52+
userId ? revokeOryOAuthSessionsForSubject(userId) : null,
53+
identityId ? revokeKratosSessionsForIdentity(identityId) : null,
54+
])
5455

5556
const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null
5657
return (logoutUrl ?? postLogoutUrl).toString()

src/features/dashboard/sidebar/menu.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client'
22

3+
import { Portal } from '@radix-ui/react-portal'
34
import Link from 'next/link'
45
import { useState } from 'react'
56
import { PROTECTED_URLS } from '@/configs/urls'
@@ -20,6 +21,7 @@ import {
2021
LogoutIcon,
2122
UnpackIcon,
2223
} from '@/ui/primitives/icons'
24+
import { Loader } from '@/ui/primitives/loader'
2325
import { SidebarMenuButton, SidebarMenuItem } from '@/ui/primitives/sidebar'
2426
import { useDashboard } from '../context'
2527
import { CreateTeamDialog } from './create-team-dialog'
@@ -29,9 +31,17 @@ import { TeamAvatar } from './team-avatar'
2931
export default function DashboardSidebarMenu() {
3032
const { team } = useDashboard()
3133
const [createTeamOpen, setCreateTeamOpen] = useState(false)
34+
// explicit state instead of useTransition: a sync transition callback
35+
// settles immediately, so isPending would flip back to false while the
36+
// sign-out action is still in flight; this stays true until the redirect
37+
// navigates away, and only resets if the action fails before that
38+
const [isLoggingOut, setIsLoggingOut] = useState(false)
3239

3340
const handleLogout = () => {
34-
signOutAction()
41+
setIsLoggingOut(true)
42+
signOutAction().catch(() => {
43+
setIsLoggingOut(false)
44+
})
3545
}
3646

3747
return (
@@ -94,6 +104,7 @@ export default function DashboardSidebarMenu() {
94104
<DropdownMenuItem
95105
variant="error"
96106
className="h-9 gap-2.5 [&_svg]:size-5 font-sans prose-body-highlight"
107+
disabled={isLoggingOut}
97108
onSelect={handleLogout}
98109
>
99110
<LogoutIcon className="ml-0.5" /> Log out
@@ -106,6 +117,12 @@ export default function DashboardSidebarMenu() {
106117
open={createTeamOpen}
107118
onOpenChange={setCreateTeamOpen}
108119
/>
120+
{isLoggingOut && (
121+
<Portal className="bg-bg/90 fixed inset-0 z-60 flex items-center justify-center gap-2.5">
122+
<Loader variant="slash" size="sm" />
123+
<span className="prose-body-highlight">Logging out...</span>
124+
</Portal>
125+
)}
109126
</>
110127
)
111128
}

0 commit comments

Comments
 (0)