chore(studio): improve HeaderBanner (#41525)

* design polish

* better banner

* defensive truncation

* better incident banner

* better prop names

* improve warnings

* fix variant

* OrganizationResourceBanner

* notice banner

* improve ClockSkewBanner

* add ARIA label

* rabbit

* lil dot

* 📝 Add docstrings to `dnywh/chore/improve-header-banner` (#41526)

* 📝 Add docstrings to `dnywh/chore/improve-header-banner`

Docstrings generation was requested by @dnywh.

* https://github.com/supabase/supabase/pull/41525#issuecomment-3680124020

The following files were modified:

* `apps/studio/hooks/misc/useOrganizationRestrictions.ts`

* new line

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>

* fix clock docs link

* Small nits

* Fix URL for grace period warning to point to usage instead of billing

* rabbit

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
authored and GitHub committed 2025-12-29 15:16:10 +11:00
1 parent fbd80939be
commit c2231301e0
10 files changed
+276 -243

No files matched your search

@@ -1,8 +1,9 @@
import { AnimatePresence, motion } from 'framer-motion'
import Link from 'next/link'
import type { ReactNode } from 'react'
import { useOrganizationRestrictions } from 'hooks/misc/useOrganizationRestrictions'
import { cn, CriticalIcon, WarningIcon } from 'ui'
import { XIcon } from 'lucide-react'
import { Button, cn, CriticalIcon, WarningIcon } from 'ui'
const bannerMotionProps = {
initial: { height: 0, opacity: 0 },
@@ -11,104 +12,110 @@ const bannerMotionProps = {
transition: { duration: 0.2, delay: 0.5 },
} as const
const linkStyles =
'[&_a]:underline [&_a]:underline-offset-2 [&_a]:decoration-foreground-muted/80 [&_a]:hover:decoration-foreground [&_a]:hover:text-foreground [&_a]:transition-all'
export const OrganizationResourceBanner = () => {
const { warnings } = useOrganizationRestrictions()
return (
<AnimatePresence initial={false}>
{warnings.map((warning, i) => (
<HeaderBanner key={i} {...warning} />
{warnings.map((warning) => (
<HeaderBanner key={`${warning.variant}-${warning.title}`} {...warning} />
))}
</AnimatePresence>
)
}
export const HeaderBanner = ({
type,
title,
message,
link,
}: {
type: 'danger' | 'warning' | 'note' | 'incident'
interface HeaderBannerProps {
variant: 'danger' | 'warning' | 'note'
title: string
message: string
link?: string
}) => {
const bannerStyles =
type === 'danger'
? 'bg-destructive-300 dark:bg-destructive-200'
: type === 'incident'
? 'bg-brand-400'
: 'bg-warning-300 dark:bg-warning-200'
const Icon = type === 'danger' ? CriticalIcon : WarningIcon
description: string | ReactNode
onDismiss?: () => void
}
const variantStyles = {
danger: {
banner: 'bg-destructive-200 border-destructive-400',
icon: 'text-destructive-200 bg-destructive-600',
},
warning: {
banner: 'bg-warning-200 border-warning-400',
icon: 'text-warning-200 bg-warning-600',
},
note: {
banner: 'bg-surface-200/25 border-default',
icon: 'text-background bg-foreground',
},
} as const
export const HeaderBanner = ({ variant, title, description, onDismiss }: HeaderBannerProps) => {
const { banner: bannerStyles, icon: iconStyles } = variantStyles[variant]
const Icon = variant === 'danger' ? CriticalIcon : WarningIcon
return (
<motion.div
{...bannerMotionProps}
className={cn(
`relative ${bannerStyles} border-b border-muted py-1 flex items-center justify-center flex-shrink-0 px-0`,
type === 'incident' && 'hover:bg-brand-300',
'flex-shrink-0'
)}
className={cn('relative border-b overflow-hidden', bannerStyles)}
layout="position"
>
<div className={cn('items-center flex flex-row gap-3')}>
<div className="absolute inset-y-0 left-0 right-0 overflow-hidden z-0">
<div
className="absolute inset-0 opacity-[0.8%]"
style={{
background: `repeating-linear-gradient(
45deg,
currentColor,
currentColor 10px,
transparent 10px,
transparent 20px
)`,
maskImage: 'linear-gradient(to top, black, transparent)',
WebkitMaskImage: 'linear-gradient(to top, black, transparent)',
}}
/>
</div>
<Icon
className={cn('z-[1] flex-shrink-0', type === 'incident' && 'bg-brand text-brand-200')}
{/* Wrapped content for smooth reveal animation */}
<div className="px-4 py-4 md:py-3 flex items-center md:justify-center">
{/* Striped background */}
<div
className="absolute inset-0 opacity-[1.6%] dark:opacity-[0.8%]"
style={{
background: `repeating-linear-gradient(
45deg,
currentColor,
currentColor 10px,
transparent 10px,
transparent 20px
)`,
maskImage: 'linear-gradient(to bottom, transparent 0%, black 90%)',
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 90%)',
}}
/>
<div className="flex flex-col md:flex-row gap-0 md:gap-3">
<span
{/* Icon and text content */}
<div
className={cn(
'relative items-start md:items-center flex flex-row gap-3 min-w-0',
// Avoid collision with the dismiss button
onDismiss && 'pr-7 md:px-7'
)}
>
<Icon className={cn('flex-shrink-0 w-5 h-5 md:w-4 md:h-4', iconStyles)} />
{/* Title and description */}
<div
className={cn(
'text-xs sm:text-sm z-[1]',
type === 'danger'
? 'text-destructive'
: type === 'incident'
? 'text-foreground'
: 'text-warning'
'flex flex-col md:flex-row gap-0.5 md:gap-1.5 text-balance md:flex-nowrap min-w-0 flex-1'
)}
>
{title}
</span>
<span
className={cn(
'text-xs sm:text-sm z-[1] opacity-75',
type === 'danger'
? 'text-destructive'
: type === 'incident'
? 'text-foreground'
: 'text-warning'
)}
>
{message}
</span>
{/* Title */}
<p className="text-sm text-foreground font-medium md:truncate">{title}</p>
{/* Description */}
<div className="flex flex-row items-center gap-1.5 min-w-0 md:flex-nowrap text-sm">
<span className="hidden md:inline text-foreground-muted">·</span>
{typeof description === 'string' ? (
<p className="text-foreground-light md:truncate">{description}</p>
) : (
<div className={cn('text-foreground-light md:truncate', linkStyles)}>
{description}
</div>
)}
</div>
</div>
</div>
{link && (
<Link
href={link}
className={cn(
'lg:block hidden',
'text-foreground-lighter text-sm z-[1] m-0',
type === 'danger' ? 'text-destructive' : 'text-warning'
)}
{onDismiss && (
<Button
type="text"
size="tiny"
className="opacity-75 z-[1] flex-shrink-0 p-0.5 h-auto absolute right-5 md:right-4 top-1/2 -translate-y-1/2"
onClick={onDismiss}
aria-label="Dismiss banner"
>
View Details
</Link>
<XIcon size={16} className="text-foreground" />
</Button>
)}
</div>
</motion.div>
@@ -1,29 +0,0 @@
// Add at the top of the file, with other imports
export const RESTRICTION_MESSAGES = {
GRACE_PERIOD: {
title: 'Organization plan has exceeded its quota',
message: (date: string) => `You are given a grace period until ${date}`,
},
GRACE_PERIOD_OVER: {
title: 'Grace period is over',
message: 'Your Projects will not be able to serve requests when you used up your quota.',
},
RESTRICTED: {
title: 'Services Restricted',
message:
'Your Projects are unable to serve any requests as your Organization plan has used up its quota.',
},
OVERDUE_INVOICES: {
title: 'Outstanding Invoices',
message: 'Please pay invoices to avoid service disruption.',
},
OVERDUE_INVOICES_FROM_OTHER_ORGS: {
title: 'Outstanding Invoices in other Organization',
message: 'Please pay invoices for other Organization to avoid service disruption.',
},
MISSING_BILLING_INFO: {
title: 'Missing Billing Information',
message:
'Please add a billing address to avoid restrictions. If you are a registered business, please add a tax ID too.',
},
} as const
@@ -0,0 +1,60 @@
import { type ReactNode } from 'react'
import { InlineLink } from 'components/ui/InlineLink'
export const RESTRICTION_MESSAGES = {
GRACE_PERIOD: {
title: 'Organization plan has exceeded its quota',
description: (date: string, slug: string): ReactNode => (
<>
You have been given a grace period until {date}.{' '}
<InlineLink href={`/org/${slug}/usage`}>Review usage</InlineLink>
</>
),
},
GRACE_PERIOD_OVER: {
title: 'Grace period is over',
description: (slug: string): ReactNode => (
<>
Your projects will not be able to serve requests when you use up your quota.{' '}
<InlineLink href={`/org/${slug}/billing`}>Review billing</InlineLink>
</>
),
},
RESTRICTED: {
title: 'Services restricted',
description: (slug: string): ReactNode => (
<>
Your projects are unable to serve requests as your organization has used up its quota.{' '}
<InlineLink href={`/org/${slug}/billing`}>Resolve billing issues</InlineLink>
</>
),
},
OVERDUE_INVOICES: {
title: 'Outstanding invoices',
description: (slug: string): ReactNode => (
<>
Please <InlineLink href={`/org/${slug}/billing#invoices`}>pay your invoices</InlineLink> to
avoid service disruption
</>
),
},
OVERDUE_INVOICES_FROM_OTHER_ORGS: {
title: 'Outstanding invoices in other organization',
description: (slug: string): ReactNode => (
<>
Please <InlineLink href={`/org/${slug}/billing#invoices`}>pay invoices</InlineLink> for
other organizations to avoid service disruption
</>
),
},
MISSING_BILLING_INFO: {
title: 'Missing billing information',
description: (slug: string): ReactNode => (
<>
Please <InlineLink href={`/org/${slug}/billing#address`}>add a billing address</InlineLink>{' '}
to avoid restrictions. If you are a registered business, please add a tax ID too
</>
),
},
}
@@ -11,7 +11,7 @@ interface IncidentAdmonitionProps {
isActive: boolean
}
const STATUS_DESCRIPTION_SIGN_OFF = 'Please follow the status page for updates.'
const STATUS_DESCRIPTION_SIGN_OFF = 'Follow the status page for updates.'
const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
@@ -1,56 +1,32 @@
import { fetchHandler } from 'data/fetchers'
import { BASE_PATH, DOCS_URL } from 'lib/constants'
import { useCallback, useEffect, useState } from 'react'
import { Button } from 'ui'
import { useEffect, useState } from 'react'
// Show the banner if the clock skew is greater than 2 minutes
const CLOCK_SKEW_THRESHOLD = 2 * 60 * 1000
// check every 5 minutes
const CLOCK_SKEW_CHECK_INTERVAL = 30 * 60 * 1000
const isClockSkewed = async () => {
try {
const response = await fetchHandler(`${BASE_PATH}/api/get-utc-time`)
const data = await response.json()
// The received time is in UTC timezone, add Z at the end to make JS understand that
const serverTime = new Date(data.utcTime).getTime()
const clientTime = new Date().getTime()
const clockSkew = Math.abs(clientTime - serverTime)
return clockSkew > CLOCK_SKEW_THRESHOLD
} catch {
return false
}
}
import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner'
import { InlineLink } from 'components/ui/InlineLink'
import { useClockSkewQuery } from 'data/misc/clock-skew-query'
import { DOCS_URL } from 'lib/constants'
export const ClockSkewBanner = () => {
const [clockSkew, setClockSkew] = useState(false)
const [isClockSkewed, setIsClockSkewed] = useState(false)
const checkClockSkew = useCallback(async () => {
const value = await isClockSkewed()
setClockSkew(value)
}, [])
const { data } = useClockSkewQuery()
useEffect(() => setIsClockSkewed(!!data), [data])
useEffect(() => {
// check for clock skew every CLOCK_SKEW_CHECK_INTERVAL
checkClockSkew()
const interval = setInterval(checkClockSkew, CLOCK_SKEW_CHECK_INTERVAL)
return () => clearInterval(interval)
}, [checkClockSkew])
if (!clockSkew) return null
if (!isClockSkewed) return null
return (
<div className="flex items-center justify-center gap-x-4 bg-destructive-400 py-0.5 transition text-foreground box-border border-b border-default">
<p className="text-sm">
Your computer's clock appears to be inaccurate. This can cause issues with certain features.
</p>
<Button asChild type="text">
<a href={`${DOCS_URL}`} target="_blank">
More information
</a>
</Button>
</div>
<HeaderBanner
variant="warning"
title="Your computer’s clock appears to be inaccurate"
description={
<>
This can cause issues with certain features.{' '}
<InlineLink
href={`${DOCS_URL}/guides/troubleshooting/jwt-expired-error-in-supabase-dashboard-F06k3x`}
>
Learn more
</InlineLink>
</>
}
/>
)
}
@@ -1,15 +1,20 @@
import Link from 'next/link'
import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner'
import { InlineLink } from 'components/ui/InlineLink'
/**
* Used to display ongoing incidents
*/
export const IncidentBanner = () => {
return (
<Link href="https://status.supabase.com" target="_blank" rel="noopener noreferrer">
<HeaderBanner
type="incident"
title="We are currently investigating a technical issue"
message="Follow status.supabase.com for updates"
/>
</Link>
<HeaderBanner
variant="note"
title="We are investigating a technical issue"
description={
<>
Follow the <InlineLink href="https://status.supabase.com">status page</InlineLink> for
updates
</>
}
/>
)
}
@@ -1,71 +1,35 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useAppBannerContext } from 'components/interfaces/App/AppBannerWrapperContext'
import { Button, WarningIcon, cn } from 'ui'
// This file, like AppBannerWrapperContext.tsx, is meant to be dynamic - update this as and when we need to use the NoticeBanner
// We can disable this banner after 23rd November 2025 as the maintenance window is complete
import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner'
import { InlineLink } from 'components/ui/InlineLink'
/**
* Used to display urgent notices that apply for all users, such as maintenance windows.
* This file, like AppBannerWrapperContext.tsx, is meant to be dynamic.
* Update this as and when we need to use the NoticeBanner.
*/
export const NoticeBanner = () => {
const router = useRouter()
const { maintenanceWindowBannerAcknowledged, onUpdateAcknowledged } = useAppBannerContext()
const appBannerContext = useAppBannerContext()
const { maintenanceWindowBannerAcknowledged, onUpdateAcknowledged } = appBannerContext
const acknowledged = maintenanceWindowBannerAcknowledged
if (router.pathname.includes('sign-in') || acknowledged) {
if (router.pathname.includes('sign-in') || maintenanceWindowBannerAcknowledged) {
return null
}
return (
<div
className={cn(
'relative bg-warning-300 dark:bg-warning-200 border-b border-muted py-1 flex items-center justify-center flex-shrink-0 px-0'
)}
>
<div className="absolute inset-y-0 left-0 right-0 overflow-hidden z-0">
<div
className="absolute inset-0 opacity-[0.8%]"
style={{
background: `repeating-linear-gradient(
45deg,
currentColor,
currentColor 10px,
transparent 10px,
transparent 20px
)`,
maskImage: 'linear-gradient(to top, black, transparent)',
WebkitMaskImage: 'linear-gradient(to top, black, transparent)',
}}
/>
</div>
<div className="items-center flex flex-row gap-3 z-[1]">
<WarningIcon className="z-[1] flex-shrink-0" />
<div className="flex-1 text-xs sm:text-sm z-[1] text-warning">
Urgent Dashboard and Management API maintenance between 23:00 UTC on Nov 21, 2025 and
23:00 UTC on Nov 23, 2025. For full details,{' '}
<Link
href="https://status.supabase.com/incidents/z0l2157y33xk"
target="_blank"
rel="noreferrer"
className="opacity-75 hover:opacity-100 underline"
>
check here
</Link>
.
</div>
<Button
type="text"
className="opacity-75 z-[1] flex-shrink-0"
onClick={() => {
onUpdateAcknowledged('maintenance-window-banner-2025-11-21')
}}
>
Dismiss
</Button>
</div>
</div>
<HeaderBanner
variant="warning"
title="Urgent dashboard and management API maintenance"
description={
<>
23:00 UTC Nov 21–23, 2025.{' '}
<InlineLink href="https://status.supabase.com/incidents/z0l2157y33xk">
Learn more
</InlineLink>
</>
}
onDismiss={() => onUpdateAcknowledged('maintenance-window-banner-2025-11-21')}
/>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query'
import { fetchHandler } from 'data/fetchers'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { miscKeys } from './keys'
// Warn if the clock skew is greater than 2 minutes
const CLOCK_SKEW_THRESHOLD = 2 * 60 * 1000
// Check every 30 minutes
const CLOCK_SKEW_CHECK_INTERVAL = 30 * 60 * 1000
export async function getClockSkew() {
try {
const data = await fetchHandler(`${BASE_PATH}/api/get-utc-time`).then((res) => res.json())
const serverTime = new Date(data.utcTime).getTime()
const clientTime = new Date().getTime()
const clockSkew = Math.abs(clientTime - serverTime)
return clockSkew > CLOCK_SKEW_THRESHOLD
} catch (error) {
throw error
}
}
export type ClockSkewData = Awaited<ReturnType<typeof getClockSkew>>
export type ClockSkewError = ResponseError
export const useClockSkewQuery = <TData = ClockSkewData>({
enabled = true,
refetchInterval = CLOCK_SKEW_CHECK_INTERVAL,
...options
}: UseCustomQueryOptions<ClockSkewData, ClockSkewError, TData> = {}) =>
useQuery<ClockSkewData, ClockSkewError, TData>({
queryKey: miscKeys.clockSkew(),
queryFn: () => getClockSkew(),
enabled: enabled && IS_PLATFORM,
refetchInterval,
...options,
})
+1
View File
@@ -3,4 +3,5 @@ export const miscKeys = {
defaultRegion: (cloudProvider: string | undefined, useRestrictedPool: boolean) =>
['defaultRegion', [cloudProvider, useRestrictedPool]] as const,
ipAddress: () => ['ip-address'] as const,
clockSkew: () => ['clock-skew'] as const,
}
@@ -1,4 +1,5 @@
import dayjs from 'dayjs'
import type { ReactNode } from 'react'
import { RESTRICTION_MESSAGES } from 'components/interfaces/Organization/restriction.constants'
import { useOverdueInvoicesQuery } from 'data/invoices/invoices-overdue-query'
@@ -7,12 +8,21 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization
import { useIsFeatureEnabled } from './useIsFeatureEnabled'
export type WarningBannerProps = {
type: 'danger' | 'warning' | 'note'
variant: 'danger' | 'warning' | 'note'
title: string
message: string
link: string
description: ReactNode
}
/**
* Compute billing-related restriction banners for the currently selected organization.
*
* The hook examines billing feature availability, overdue invoices, organization billing data,
* and restriction status to produce an ordered list of warning/danger banner props.
*
* @returns An object containing:
* - `warnings`: an array of `WarningBannerProps` to display for the selected organization (may be empty).
* - `org`: the selected organization data (may be `undefined`).
*/
export function useOrganizationRestrictions() {
const { data: org } = useSelectedOrganizationQuery()
@@ -35,57 +45,57 @@ export function useOrganizationRestrictions() {
if (org && org.organization_missing_address && !org.billing_partner) {
warnings.push({
type: 'danger',
variant: 'danger',
title: RESTRICTION_MESSAGES.MISSING_BILLING_INFO.title,
message: RESTRICTION_MESSAGES.MISSING_BILLING_INFO.message,
link: `/org/${org?.slug}/billing#address`,
description: RESTRICTION_MESSAGES.MISSING_BILLING_INFO.description(org.slug),
})
}
if (thisOrgHasOverdueInvoices?.length) {
warnings.push({
type: 'danger',
variant: 'danger',
title: RESTRICTION_MESSAGES.OVERDUE_INVOICES.title,
message: RESTRICTION_MESSAGES.OVERDUE_INVOICES.message,
link: `/org/${org?.slug}/billing#invoices`,
description: RESTRICTION_MESSAGES.OVERDUE_INVOICES.description(org?.slug ?? 'default'),
})
}
if (overdueInvoicesFromOtherOrgs?.length) {
const otherOrgSlug = organizations?.find(
(o) => o.id === overdueInvoicesFromOtherOrgs[0].organization_id
)?.slug
warnings.push({
type: 'danger',
variant: 'danger',
title: RESTRICTION_MESSAGES.OVERDUE_INVOICES_FROM_OTHER_ORGS.title,
message: RESTRICTION_MESSAGES.OVERDUE_INVOICES_FROM_OTHER_ORGS.message,
link: `/org/${organizations ? organizations?.find((org) => org.id === overdueInvoicesFromOtherOrgs[0].organization_id)?.slug : org?.slug}/billing#invoices`,
description: RESTRICTION_MESSAGES.OVERDUE_INVOICES_FROM_OTHER_ORGS.description(
otherOrgSlug ?? org?.slug ?? 'default'
),
})
}
if (org?.restriction_status === 'grace_period') {
warnings.push({
type: 'warning',
variant: 'warning',
title: RESTRICTION_MESSAGES.GRACE_PERIOD.title,
message: RESTRICTION_MESSAGES.GRACE_PERIOD.message(
dayjs(org?.restriction_data?.['grace_period_end']).format('DD MMM, YYYY')
description: RESTRICTION_MESSAGES.GRACE_PERIOD.description(
dayjs(org?.restriction_data?.['grace_period_end']).format('DD MMM, YYYY'),
org.slug
),
link: `/org/${org?.slug}/billing`,
})
}
if (org?.restriction_status === 'grace_period_over') {
warnings.push({
type: 'warning',
variant: 'warning',
title: RESTRICTION_MESSAGES.GRACE_PERIOD_OVER.title,
message: RESTRICTION_MESSAGES.GRACE_PERIOD_OVER.message,
link: `/org/${org?.slug}/billing`,
description: RESTRICTION_MESSAGES.GRACE_PERIOD_OVER.description(org.slug),
})
}
if (org?.restriction_status === 'restricted') {
warnings.push({
type: 'danger',
variant: 'danger',
title: RESTRICTION_MESSAGES.RESTRICTED.title,
message: RESTRICTION_MESSAGES.RESTRICTED.message,
link: `/org/${org?.slug}/billing`,
description: RESTRICTION_MESSAGES.RESTRICTED.description(org.slug),
})
}