From c2231301e0a05343310c0f36e91adb06ad0ad32e Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Mon, 29 Dec 2025 14:16:10 +1000 Subject: [PATCH] chore(studio): improve HeaderBanner (#41525) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../interfaces/Organization/HeaderBanner.tsx | 161 +++++++++--------- .../Organization/restriction.constants.ts | 29 ---- .../Organization/restriction.constants.tsx | 60 +++++++ .../interfaces/Support/IncidentAdmonition.tsx | 2 +- .../layouts/AppLayout/ClockSkewBanner.tsx | 70 +++----- .../layouts/AppLayout/IncidentBanner.tsx | 23 ++- .../layouts/AppLayout/NoticeBanner.tsx | 80 +++------ apps/studio/data/misc/clock-skew-query.ts | 39 +++++ apps/studio/data/misc/keys.ts | 1 + .../hooks/misc/useOrganizationRestrictions.ts | 54 +++--- 10 files changed, 276 insertions(+), 243 deletions(-) delete mode 100644 apps/studio/components/interfaces/Organization/restriction.constants.ts create mode 100644 apps/studio/components/interfaces/Organization/restriction.constants.tsx create mode 100644 apps/studio/data/misc/clock-skew-query.ts diff --git a/apps/studio/components/interfaces/Organization/HeaderBanner.tsx b/apps/studio/components/interfaces/Organization/HeaderBanner.tsx index 46dae901a56..ed7f8d7fe40 100644 --- a/apps/studio/components/interfaces/Organization/HeaderBanner.tsx +++ b/apps/studio/components/interfaces/Organization/HeaderBanner.tsx @@ -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 ( - {warnings.map((warning, i) => ( - + {warnings.map((warning) => ( + ))} ) } -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 ( -
-
-
-
- + {/* Striped background */} +
-
- + + {/* Title and description */} +
- {title} - - - {message} - + {/* Title */} +

{title}

+ {/* Description */} +
+ · + {typeof description === 'string' ? ( +

{description}

+ ) : ( +
+ {description} +
+ )} +
+
- {link && ( - - View Details - + + )}
diff --git a/apps/studio/components/interfaces/Organization/restriction.constants.ts b/apps/studio/components/interfaces/Organization/restriction.constants.ts deleted file mode 100644 index a22379fced8..00000000000 --- a/apps/studio/components/interfaces/Organization/restriction.constants.ts +++ /dev/null @@ -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 diff --git a/apps/studio/components/interfaces/Organization/restriction.constants.tsx b/apps/studio/components/interfaces/Organization/restriction.constants.tsx new file mode 100644 index 00000000000..39decb10a6d --- /dev/null +++ b/apps/studio/components/interfaces/Organization/restriction.constants.tsx @@ -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}.{' '} + Review usage + + ), + }, + 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.{' '} + Review billing + + ), + }, + RESTRICTED: { + title: 'Services restricted', + description: (slug: string): ReactNode => ( + <> + Your projects are unable to serve requests as your organization has used up its quota.{' '} + Resolve billing issues + + ), + }, + OVERDUE_INVOICES: { + title: 'Outstanding invoices', + description: (slug: string): ReactNode => ( + <> + Please pay your invoices to + avoid service disruption + + ), + }, + OVERDUE_INVOICES_FROM_OTHER_ORGS: { + title: 'Outstanding invoices in other organization', + description: (slug: string): ReactNode => ( + <> + Please pay invoices for + other organizations to avoid service disruption + + ), + }, + MISSING_BILLING_INFO: { + title: 'Missing billing information', + description: (slug: string): ReactNode => ( + <> + Please add a billing address{' '} + to avoid restrictions. If you are a registered business, please add a tax ID too + + ), + }, +} diff --git a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx index 2bbe58f10b6..129570067f1 100644 --- a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx +++ b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx @@ -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) diff --git a/apps/studio/components/layouts/AppLayout/ClockSkewBanner.tsx b/apps/studio/components/layouts/AppLayout/ClockSkewBanner.tsx index 61bbe5881a7..50174f78d17 100644 --- a/apps/studio/components/layouts/AppLayout/ClockSkewBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/ClockSkewBanner.tsx @@ -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 ( -
-

- Your computer's clock appears to be inaccurate. This can cause issues with certain features. -

- -
+ + This can cause issues with certain features.{' '} + + Learn more + + + } + /> ) } diff --git a/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx b/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx index bd92d66a236..4890f795e0c 100644 --- a/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx @@ -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 ( - - - + + Follow the status page for + updates + + } + /> ) } diff --git a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx index 4e037219cf7..555c63a084f 100644 --- a/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/NoticeBanner.tsx @@ -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 ( -
-
-
-
-
- -
- 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,{' '} - - check here - - . -
- -
-
+ + 23:00 UTC Nov 21–23, 2025.{' '} + + Learn more + + + } + onDismiss={() => onUpdateAcknowledged('maintenance-window-banner-2025-11-21')} + /> ) } diff --git a/apps/studio/data/misc/clock-skew-query.ts b/apps/studio/data/misc/clock-skew-query.ts new file mode 100644 index 00000000000..a2a0ef5c8d6 --- /dev/null +++ b/apps/studio/data/misc/clock-skew-query.ts @@ -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> +export type ClockSkewError = ResponseError + +export const useClockSkewQuery = ({ + enabled = true, + refetchInterval = CLOCK_SKEW_CHECK_INTERVAL, + ...options +}: UseCustomQueryOptions = {}) => + useQuery({ + queryKey: miscKeys.clockSkew(), + queryFn: () => getClockSkew(), + enabled: enabled && IS_PLATFORM, + refetchInterval, + ...options, + }) diff --git a/apps/studio/data/misc/keys.ts b/apps/studio/data/misc/keys.ts index 7ba0a4c11c1..c859f6ad908 100644 --- a/apps/studio/data/misc/keys.ts +++ b/apps/studio/data/misc/keys.ts @@ -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, } diff --git a/apps/studio/hooks/misc/useOrganizationRestrictions.ts b/apps/studio/hooks/misc/useOrganizationRestrictions.ts index 54bdd88a012..66435f04981 100644 --- a/apps/studio/hooks/misc/useOrganizationRestrictions.ts +++ b/apps/studio/hooks/misc/useOrganizationRestrictions.ts @@ -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), }) }