chore(studio): session expired dialog (#43122)

## What kind of change does this PR introduce?

UI improvement

## What is the current behavior?

The session expired dialog is quite hard to parse when, for most people,
the ask is simple.

## What is the new behavior?

Improved session expired dialog:

- Refactored to use AlertDialog
- Clarified copywriting
- Uses the new AlertCollapsible to hide all the complicated debugging
steps under a toggle
	- This component is documented in the design-system

| Before | After |
| --- | --- |
| <img width="1024" height="563"
alt="Supabase-B1728A05-DDD2-4A50-AED4-D62EAA2E7D7C"
src="https://github.com/user-attachments/assets/771f85d8-21ea-42b5-99f5-b9b05f5617dd"
/> | <img width="1024" height="563" alt="Storage Supabase"
src="https://github.com/user-attachments/assets/b2fcab68-fb29-4cb9-bd42-aecc57b9fa32"
/> |
| <img width="1024" height="563"
alt="Supabase-B1728A05-DDD2-4A50-AED4-D62EAA2E7D7C"
src="https://github.com/user-attachments/assets/771f85d8-21ea-42b5-99f5-b9b05f5617dd"
/> | <img width="1024" height="563" alt="Storage Supabase"
src="https://github.com/user-attachments/assets/babd3711-5fdf-43b9-be06-d96268a191fd"
/> |

## To test

In apps/studio/hooks/misc/withAuth.tsx:

```diff
- const [isSessionTimeoutModalOpen, setIsSessionTimeoutModalOpen] = useState(false)
+ const [isSessionTimeoutModalOpen, setIsSessionTimeoutModalOpen] = useState(true) // Mocked as true for UI testing
```

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2026-02-25 16:27:48 +08:00
1 parent 4f26af6259
commit 22ff9b2d81
12 files changed
+297 -159

No files matched your search

+11
View File
@@ -148,6 +148,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"alert-collapsible-demo": {
name: "alert-collapsible-demo",
type: "components:example",
registryDependencies: ["alert","collapsible","button"],
component: React.lazy(() => import("@/registry/default/example/alert-collapsible-demo")),
source: "",
files: ["registry/default/example/alert-collapsible-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"alert-dialog-demo": {
name: "alert-dialog-demo",
type: "components:example",
@@ -1,67 +1,37 @@
---
title: Collapsible
description: An interactive component which expands/collapses a panel.
title: Alert Collapsible
description: An alert-styled block with a collapsible content section, toggled by an icon button.
component: true
featured: true
links:
doc: https://www.radix-ui.com/docs/primitives/components/collapsible
api: https://www.radix-ui.com/docs/primitives/components/collapsible#api-reference
source:
radix: true
shadcn: true
---
<ComponentPreview name="collapsible-demo" peekCode wide />
## Installation
<Tabs defaultValue="cli">
<TabsList>
<TabsTrigger value="cli">CLI</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
</TabsList>
<TabsContent value="cli">
```bash
npx shadcn-ui@latest add collapsible
```
</TabsContent>
<TabsContent value="manual">
<Steps>
<Step>Install the following dependencies:</Step>
```bash
npm install @radix-ui/react-collapsible
```
<Step>Copy and paste the following code into your project.</Step>
<ComponentSource name="collapsible" />
<Step>Update the import paths to match your project setup.</Step>
</Steps>
</TabsContent>
</Tabs>
<ComponentPreview name="alert-collapsible-demo" peekCode wide />
## Usage
```tsx
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { AlertCollapsible } from 'ui'
```
```tsx
<Collapsible>
<CollapsibleTrigger>Can I use this in my project?</CollapsibleTrigger>
<CollapsibleContent>
Yes. Free to use for personal and commercial projects. No attribution required.
</CollapsibleContent>
</Collapsible>
<AlertCollapsible trigger="Need help?">
<p>Content revealed when expanded.</p>
</AlertCollapsible>
```
### Default open
```tsx
<AlertCollapsible trigger="Details" defaultOpen>
<p>This section is expanded by default.</p>
</AlertCollapsible>
```
### Variants
Supports the same `variant` prop as `Alert`: `default`, `destructive`, and `warning`.
```tsx
<AlertCollapsible trigger="Warning" variant="warning">
<p>Something needs attention.</p>
</AlertCollapsible>
```
@@ -0,0 +1,11 @@
import { AlertCollapsible } from 'ui'
export default function AlertCollapsibleDemo() {
return (
<AlertCollapsible trigger="Need help?">
<p className="text-sm text-foreground-light">
Try a different browser or disable extensions that block network requests.
</p>
</AlertCollapsible>
)
}
+6
View File
@@ -43,6 +43,12 @@ export const examples: Registry = [
registryDependencies: ['alert'],
files: ['example/alert-demo.tsx'],
},
{
name: 'alert-collapsible-demo',
type: 'components:example',
registryDependencies: ['alert', 'collapsible', 'button'],
files: ['example/alert-collapsible-demo.tsx'],
},
{
name: 'alert-dialog-demo',
type: 'components:example',
@@ -1,21 +1,36 @@
import { SupportCategories } from '@supabase/shared-types/out/constants'
import * as Sentry from '@sentry/nextjs'
import { useEffect } from 'react'
import { toast } from 'sonner'
import { InlineLink, InlineLinkClassName } from 'components/ui/InlineLink'
import { toast } from 'sonner'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
AlertCollapsible,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
} from 'ui'
import { SupportLink } from '../Support/SupportLink'
interface SessionTimeoutModalProps {
visible: boolean
onClose: () => void
redirectToSignIn: () => void
/** Optional context so the support form can pre-populate when opened from this dialog */
supportContext?: { projectRef?: string; orgSlug?: string }
}
export const SessionTimeoutModal = ({
visible,
onClose,
redirectToSignIn,
supportContext,
}: SessionTimeoutModalProps) => {
useEffect(() => {
if (visible) {
@@ -23,60 +38,70 @@ export const SessionTimeoutModal = ({
}
}, [visible])
const handleClearStorage = () => {
try {
localStorage.clear()
sessionStorage.clear()
} catch (e) {
toast.error('Failed to clear browser storage')
}
window.location.reload()
}
return (
<ConfirmationModal
visible={visible}
title="Session timed out"
confirmLabel="Sign in again"
onCancel={onClose}
onConfirm={redirectToSignIn}
alert={{
base: { variant: 'warning' },
title: 'Your session has timed out',
description:
'Please try signing in again. If you are not able to sign in again, please contact Support.',
<AlertDialog
open={visible}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<div className="space-y-4 text-sm text-foreground-light">
<ul className="list-disc pl-1.5 list-inside space-y-1 text-sm text-foreground-light">
<li>Try with a different browser</li>
<li>Disable browser extensions that block network requests</li>
<li>
<button
title="Clear storage and reload"
className="underline"
onClick={() => {
try {
localStorage.clear()
sessionStorage.clear()
} catch (e) {
toast.error('Failed to clear browser storage')
}
window.location.reload()
}}
>
Clear your browser storage
</button>
</li>
</ul>
<p>
If none of these steps work, please{' '}
<SupportLink
className={InlineLinkClassName}
queryParams={{ subject: 'Session timed out' }}
>
Contact support
</SupportLink>
.
</p>
<p>
Consider{' '}
<InlineLink href="https://github.com/orgs/supabase/discussions/36540">
generating a HAR file
</InlineLink>{' '}
from your session to help Support pinpoint the issue.
</p>
</div>
</ConfirmationModal>
<AlertDialogContent size="small">
<AlertDialogHeader>
<AlertDialogTitle>Session expired</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<p>Please sign in again to continue.</p>
<AlertCollapsible trigger="Having trouble?">
<div className="space-y-3 text-foreground-light">
<p>
Try a different browser or disable extensions that block network requests. If
the problem persists:
</p>
<Button type="default" size="tiny" onClick={handleClearStorage}>
Clear site data and reload
</Button>
<p>
Still stuck?{' '}
<SupportLink
className={InlineLinkClassName}
queryParams={{
subject: 'Session expired',
category: SupportCategories.LOGIN_ISSUES,
...(supportContext?.projectRef && {
projectRef: supportContext.projectRef,
}),
...(supportContext?.orgSlug && { orgSlug: supportContext.orgSlug }),
}}
onClick={onClose}
>
Contact support
</SupportLink>{' '}
and include a{' '}
<InlineLink href="https://github.com/orgs/supabase/discussions/36540">
HAR file
</InlineLink>{' '}
from your session to help us investigate.
</p>
</div>
</AlertCollapsible>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Close</AlertDialogCancel>
<AlertDialogAction onClick={redirectToSignIn}>Sign in again</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -1,5 +1,11 @@
// End of third-party imports
import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachment-urls-mutation'
import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { compact } from 'lodash'
import { Plus, X } from 'lucide-react'
import { File, FileCode, Plus, X } from 'lucide-react'
import { InlineLink } from 'components/ui/InlineLink'
import {
useCallback,
useEffect,
@@ -10,12 +16,8 @@ import {
type RefObject,
} from 'react'
import { toast } from 'sonner'
// End of third-party imports
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachment-urls-mutation'
import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { cn } from 'ui'
import { createSupportStorageClient } from './support-storage-client'
const MAX_ATTACHMENTS = 5
@@ -26,7 +28,7 @@ const uploadAttachments = async ({ userId, files }: { userId: string; files: Fil
const filesToUpload = Array.from(files)
const uploadedFiles = await Promise.all(
filesToUpload.map(async (file) => {
const suffix = file.type.split('/')[1]
const suffix = file.name.endsWith('.har') ? 'har' : file.type.split('/')[1]
const prefix = `${userId}/${uuidv4()}.${suffix}`
const options = { cacheControl: '3600' }
@@ -87,7 +89,13 @@ export function useAttachmentUpload() {
useEffect(() => {
if (!uploadedFiles) return
const objectUrls = uploadedFiles.map((file) => URL.createObjectURL(file))
const objectUrls = uploadedFiles.map((file) => {
if (file.name.endsWith('.har')) {
return file.name
} else {
return URL.createObjectURL(file)
}
})
setUploadedDataUrls(objectUrls)
return () => {
@@ -158,10 +166,12 @@ export function AttachmentUploadDisplay({
return (
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-1">
<p className="text-sm text-foreground-light">Attachments</p>
<p className="text-sm text-foreground">Attachments</p>
<p className="text-sm text-foreground-lighter">
Upload up to {MAX_ATTACHMENTS} screenshots that might be relevant to the issue that you're
facing
Optionally upload up to {MAX_ATTACHMENTS} relevant images or{' '}
<InlineLink href="https://github.com/orgs/supabase/discussions/36540">
HAR files
</InlineLink>
</p>
</div>
<input
@@ -169,29 +179,64 @@ export function AttachmentUploadDisplay({
type="file"
ref={uploadButtonRef}
className="hidden"
accept="image/png, image/jpeg"
accept="image/png, image/jpeg, .har"
onChange={handleFileUpload}
/>
<div className="flex items-center gap-x-2">
{uploadedDataUrls.map((url, idx) => (
<div
key={url}
style={{ backgroundImage: `url("${url}")` }}
className="relative h-14 w-14 rounded bg-cover bg-center bg-no-repeat"
>
<button
type="button"
aria-label="Remove attachment"
className={cn(
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
'absolute -top-1 -right-1 cursor-pointer'
)}
onClick={() => removeFileUpload(idx)}
>
<X aria-hidden="true" size={12} strokeWidth={2} />
</button>
</div>
))}
{uploadedDataUrls.map((url, idx) => {
if (url.endsWith('.har')) {
return (
<div
key={url}
className="border relative h-14 w-14 rounded flex items-center justify-center"
>
<Tooltip>
<TooltipTrigger className="cursor-default" onClick={(e) => e.preventDefault()}>
<div className="flex flex-col items-center justify-center gap-y-1">
<FileCode className="text-foreground-light" size={16} />
<p className="text-[10px] font-mono text-foreground-light tracking-wide leading-none">
HAR
</p>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{url}</TooltipContent>
</Tooltip>
<button
type="button"
aria-label="Remove attachment"
className={cn(
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
'absolute -top-1 -right-1 cursor-pointer'
)}
onClick={() => removeFileUpload(idx)}
>
<X aria-hidden="true" size={10} strokeWidth={3} className="text-contrast" />
</button>
</div>
)
} else {
return (
<div
key={url}
style={{ backgroundImage: `url("${url}")` }}
className="relative h-14 w-14 rounded bg-cover bg-center bg-no-repeat"
>
<button
type="button"
aria-label="Remove attachment"
className={cn(
'flex h-4 w-4 items-center justify-center rounded-full bg-red-900',
'absolute -top-1 -right-1 cursor-pointer'
)}
onClick={() => removeFileUpload(idx)}
>
<X aria-hidden="true" size={10} strokeWidth={3} className="text-contrast" />
</button>
</div>
)
}
})}
{!isFull && (
<button
type="button"
@@ -1,23 +1,25 @@
// End of third-party imports
import {
DocsSearchResultType as PageType,
type DocsSearchResult as Page,
type DocsSearchResultSection as PageSection,
} from 'common'
import { getProjectDetail } from 'data/projects/project-detail-query'
import dayjs from 'dayjs'
import { DOCS_URL } from 'lib/constants'
import { partition } from 'lodash'
import { Book, Github, Hash, MessageSquare } from 'lucide-react'
import {
createLoader,
createParser,
createSerializer,
type inferParserType,
parseAsString,
type inferParserType,
type UseQueryStatesKeysMap,
} from 'nuqs'
// End of third-party imports
import {
type DocsSearchResult as Page,
type DocsSearchResultSection as PageSection,
DocsSearchResultType as PageType,
} from 'common'
import { getProjectDetail } from 'data/projects/project-detail-query'
import dayjs from 'dayjs'
import { DOCS_URL } from 'lib/constants'
import type { Organization } from 'types'
import { CATEGORY_OPTIONS } from './Support.constants'
export const NO_PROJECT_MARKER = 'no-project'
@@ -32,10 +34,13 @@ export const formatMessage = ({
attachments?: Array<string>
error: string | null | undefined
}) => {
const [harFiles, images] = partition(attachments, (x) => x.split('?token')[0].endsWith('.har'))
const errorString = error != null ? `\n\nError: ${error}` : ''
const attachmentsString =
attachments.length > 0 ? `\n\nAttachments:\n${attachments.join('\n')}` : ''
return `${message}${errorString}${attachmentsString}`
const imagesString = images.length > 0 ? `\n\nImage Attachments:\n${images.join('\n\n')}` : ''
const harFilesString = harFiles.length > 0 ? `\n\nHAR Files:\n${harFiles.join('\n\n')}` : ''
return `${message}${errorString}${imagesString}${harFilesString}`
}
export const formatStudioVersion = (commit: { commitSha: string; commitTime: string }): string => {
+14 -4
View File
@@ -1,13 +1,12 @@
import { useRouter } from 'next/router'
import { ComponentType, useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useAuth } from 'common'
import { SessionTimeoutModal } from 'components/interfaces/SignIn/SessionTimeoutModal'
import { usePermissionsQuery } from 'data/permissions/permissions-query'
import { useAuthenticatorAssuranceLevelQuery } from 'data/profile/mfa-authenticator-assurance-level-query'
import { useSignOut } from 'lib/auth'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { useRouter } from 'next/router'
import { ComponentType, useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { isNextPageWithLayout, type NextPageWithLayout } from 'types'
const MAX_TIMEOUT = 10000 // 10 seconds
@@ -125,12 +124,23 @@ export function withAuth<T>(
const InnerComponent = WrappedComponent as any
const supportContext =
typeof router.query.ref === 'string' && router.pathname.startsWith('/project/')
? {
projectRef: router.query.ref,
...(typeof router.query.organizationSlug === 'string' && {
orgSlug: router.query.organizationSlug,
}),
}
: undefined
return (
<>
<SessionTimeoutModal
visible={isSessionTimeoutModalOpen}
onClose={() => setIsSessionTimeoutModalOpen(false)}
redirectToSignIn={redirectToSignIn}
supportContext={supportContext}
/>
<InnerComponent {...props} />
</>
+2
View File
@@ -127,6 +127,8 @@ export {
AlertDescription as AlertDescription_Shadcn_,
} from './src/components/shadcn/ui/alert'
export * from './src/components/AlertCollapsible'
export {
Field as Field_Shadcn_,
FieldContent as FieldContent_Shadcn_,
@@ -0,0 +1,51 @@
'use client'
import { type VariantProps } from 'class-variance-authority'
import { ChevronDown } from 'lucide-react'
import * as React from 'react'
import { cn } from '../../lib/utils/cn'
import { Button } from '../Button'
import { alertVariants } from '../shadcn/ui/alert'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../shadcn/ui/collapsible'
export interface AlertCollapsibleProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'>,
VariantProps<typeof alertVariants> {
trigger: React.ReactNode
defaultOpen?: boolean
}
const AlertCollapsible = React.forwardRef<HTMLDivElement, AlertCollapsibleProps>(
({ className, variant = 'default', trigger, defaultOpen, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
alertVariants({ variant }),
// Override default padding to be tighter
'p-3',
className
)}
{...props}
>
<Collapsible defaultOpen={defaultOpen}>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{trigger}</span>
<CollapsibleTrigger asChild>
<Button
type="outline"
size="tiny"
className={cn('!px-0 w-[26px]', '[&[data-state=open]_svg]:rotate-180')}
aria-label="Toggle"
icon={<ChevronDown className="transition-transform duration-200" />}
/>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="pt-3">{children}</CollapsibleContent>
</Collapsible>
</div>
)
)
AlertCollapsible.displayName = 'AlertCollapsible'
export { AlertCollapsible }
@@ -0,0 +1,2 @@
export { AlertCollapsible } from './AlertCollapsible'
export type { AlertCollapsibleProps } from './AlertCollapsible'
@@ -10,7 +10,7 @@ export interface CollapsibleProps extends RadixCollapsible.CollapsibleProps {
}
/**
* @deprecated Use `import { Collapsible_shadcn_ } from "ui"` instead
* @deprecated Use `import { AlertCollapsible } from "ui"` instead
*/
export const Collapsible = ({
open = undefined,