From edfcc485b0082fa81e2b1cce3802d3880f82f555 Mon Sep 17 00:00:00 2001 From: Taryn King <49492414+tk1ng@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:52:57 -0500 Subject: [PATCH 01/28] fix(docs): correct topics values to align with expected enums (#45140) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Docs update to correct frontmatter values in topics array to align with expected enums. In this case "REST-API" is not one of the excepted enum values for the topics array. Excepted values: ``` topics: z.array( z.enum([ 'ai', 'auth', 'branching', 'cli', 'database', 'functions', 'platform', 'realtime', 'self-hosting', 'storage', 'studio', 'supavisor', 'terraform', ]) ), ``` Added keywords for the additional value. ## What is the current behavior? These troubleshooting guides are not rendered in front-facing docs despite being available in the content/troubleshooting directory due to error validating troubleshooting metadata ## What is the new behavior? These troubleshooting guides are now rendered. ## Summary by CodeRabbit * **Documentation** * Updated frontmatter in troubleshooting guides: removed "rest-api" from topics and added a "REST API" keyword to improve content organization and searchability. --------- Co-authored-by: Chandana Anumula <129955975+canumula@users.noreply.github.com> --- .../postgrest-not-recognizing-objects-in-schema.mdx | 3 +-- .../schema-pg_pgrst_no_exposed_schemas-does-not-exist.mdx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/docs/content/troubleshooting/postgrest-not-recognizing-objects-in-schema.mdx b/apps/docs/content/troubleshooting/postgrest-not-recognizing-objects-in-schema.mdx index 86feb98ab68..b356ee3d0e0 100644 --- a/apps/docs/content/troubleshooting/postgrest-not-recognizing-objects-in-schema.mdx +++ b/apps/docs/content/troubleshooting/postgrest-not-recognizing-objects-in-schema.mdx @@ -2,10 +2,9 @@ title = "PostgREST not recognizing the objects(tables/functions/views) in a schema even after adding it to the exposed schemas" topics = [ "database", -"rest-api", "platform" ] -keywords = [] +keywords = ["REST API"] --- PostgREST is returning errors by not recognizing the objects(tables/functions/views) in a schema and logging errors similar to: diff --git a/apps/docs/content/troubleshooting/schema-pg_pgrst_no_exposed_schemas-does-not-exist.mdx b/apps/docs/content/troubleshooting/schema-pg_pgrst_no_exposed_schemas-does-not-exist.mdx index a4247cfaabc..41e41acd67e 100644 --- a/apps/docs/content/troubleshooting/schema-pg_pgrst_no_exposed_schemas-does-not-exist.mdx +++ b/apps/docs/content/troubleshooting/schema-pg_pgrst_no_exposed_schemas-does-not-exist.mdx @@ -2,9 +2,9 @@ title = "schema \"pg_pgrst_no_exposed_schemas\" does not exist" topics = [ "database", -"rest-api", "platform" ] +keywords = ["REST API"] [[errors]] message = "schema pg_pgrst_no_exposed_schemas does not exist" --- From e51df9be2ac0faebbf5006fdeb1472020f9d6fd1 Mon Sep 17 00:00:00 2001 From: Saba Pochkhua Date: Thu, 23 Apr 2026 11:18:33 +0400 Subject: [PATCH 02/28] Add Saba Pochkhua to humans.txt (#45128) --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 4a26c6e2ba6..607ee2dab7f 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -208,6 +208,7 @@ Ronan Lehane Rory Wilding Ryan Goulet Ruan Maia +Saba Pochkhua Sam Meech-Ward Sam Rome Sam Rose From b99a9ea416af7eb4eb9d0e63c2dead4cf21961af Mon Sep 17 00:00:00 2001 From: hallidayo <22655069+Hallidayo@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:09:10 +0100 Subject: [PATCH 03/28] docs: react native expo user management update (#42300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Docs - [React Native Expo user management guide](https://supabase.com/docs/guides/getting-started/tutorials/with-expo-react-native) ## What is the new behavior? Guide has been updated to be in line with #42269 - Removing `@rneui/themed` package and using native components. - Update guide screenshot. - Main stylesheet for the example. ## Summary by CodeRabbit ## Documentation - Updated Expo React Native tutorial with modernized component approach using React Native primitives and centralized styling patterns. - Simplified codebase examples with consistent formatting and improved maintainability. - Core functionality preserved with updated implementation patterns. ✏️ Tip: You can customize this high-level summary in your review settings. --------- Co-authored-by: Chris Chinchilla Co-authored-by: Chris Chinchilla Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../tutorials/with-expo-react-native.mdx | 18 ++++- .../img/supabase-expo-react-native-demo.png | Bin 0 -> 70475 bytes .../expo-user-management/.prettierrc | 14 ---- .../expo-user-management/App.tsx | 1 - .../components/Account.tsx | 70 +++++++++-------- .../expo-user-management/components/Auth.tsx | 55 +++++++------- .../components/Avatar.tsx | 71 ++++++++---------- .../expo-user-management/styles/styles.ts | 70 +++++++++++++++++ 8 files changed, 180 insertions(+), 119 deletions(-) create mode 100644 apps/docs/public/img/supabase-expo-react-native-demo.png delete mode 100644 examples/user-management/expo-user-management/.prettierrc create mode 100644 examples/user-management/expo-user-management/styles/styles.ts diff --git a/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx b/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx index 08abb969fa0..0e7a738170c 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx @@ -6,7 +6,7 @@ tocVideo: 'AE7dKIKMJy4' <$Partial path="quickstart_intro.mdx" /> -![Supabase User Management example](/docs/img/supabase-flutter-demo.png) +![Supabase User Management example](/docs/img/supabase-expo-react-native-demo.png) @@ -37,7 +37,7 @@ cd expo-user-management Then install the additional dependencies: ```bash -npx expo install @supabase/supabase-js @rneui/themed expo-sqlite +npx expo install @supabase/supabase-js @react-native-async-storage/async-storage ``` Now create a helper file to initialize the Supabase client using the API URL and the key that you copied [earlier](#get-api-details). @@ -75,7 +75,7 @@ These variables are safe to expose in your Expo app since Supabase has ```bash npm install @supabase/supabase-js - npm install @rneui/themed @react-native-async-storage/async-storage + npm install @react-native-async-storage/async-storage npm install aes-js react-native-get-random-values npm install --save-dev @types/aes-js npx expo install expo-secure-store @@ -156,6 +156,16 @@ These variables are safe to expose in your Expo app since Supabase has +### App styling + +You can use the following `StyleSheet` component in `styles/styles.ts` to add style to the app: + +<$CodeSample +path="/user-management/expo-user-management/styles/styles.ts" +lines={[[1, -1]]} +meta="name=styles/styles.ts" +/> + ### Set up a login component Set up a React Native component to manage logins and sign ups. @@ -177,7 +187,7 @@ While testing, you can disable email confirmation in your [project's email auth ### Account page -After a user signs in, you can let them to edit their profile details and manage their account. +After a user signs in, let them edit their profile details and manage their account. Create a new component for that called `Account.tsx`. diff --git a/apps/docs/public/img/supabase-expo-react-native-demo.png b/apps/docs/public/img/supabase-expo-react-native-demo.png new file mode 100644 index 0000000000000000000000000000000000000000..bbb96293d3eed6814b064db946ee873bb22e33fc GIT binary patch literal 70475 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 { if (userId) getProfile() @@ -66,17 +67,15 @@ export default function Account({ userId, email }: { userId: string; email?: str if (error) { throw error } - } catch (error) { - if (error instanceof Error) { - Alert.alert(error.message) - } + } catch (error: any) { + Alert.alert(error.message) } finally { setLoading(false) } } return ( - + - + Email + - setUsername(text)} /> + Username + setUsername(text)} + style={styles.input} + /> - setWebsite(text)} /> - - - - + + + + ), + }, { type: 'single-column', id: 'how-to-enter', diff --git a/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx b/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx index 3c9a9ee0121..9341ea09151 100644 --- a/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx +++ b/apps/www/_go/events/postgresconf-sjc-2026/contest.tsx @@ -84,15 +84,22 @@ const page: GoPageInput = { )} - +
+ + +
), }, From 28a2c25e0e7a3ba605c04ac2a22bf91b73b92f6e Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Thu, 23 Apr 2026 08:06:03 -0600 Subject: [PATCH 09/28] feat(studio): add navigation shortcuts with hover tooltips (#45127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `` component: binds a registered shortcut + renders a Linear-style hover tooltip (label + keybind pills) in one declaration - Adds `` as the lower-level primitive for cases where binding lives elsewhere - Registers 13 G-chord navigation shortcuts (`G H` / `G T` / `G S` / `G D` / `G A` / `G B` / `G F` / `G R` / `G V` / `G O` / `G L` / `G I` / `G ,`) - Wires shortcuts into `SideBarNavLink` — binding + dynamic-delay tooltip (0ms collapsed, 1000ms expanded) replaces the old label-only collapsed tooltip Closes [FE-3048](https://linear.app/supabase/issue/FE-3048/create-navigation-shortcuts). ## Test plan - [x] Hover each main sidebar nav item with sidebar expanded — tooltip appears after delay with label + shortcut pills - [x] Collapse sidebar — tooltip appears instantly on hover - [x] Press `G` then a registered letter anywhere outside text inputs — navigates to that screen - [x] Press `G` inside a text input — no navigation fires - [x] Disabled nav items (project inactive) — no shortcut fires, no tooltip anomaly - [x] Feature-gated routes (auth/storage/realtime off) — shortcut is gone with the route ## Summary by CodeRabbit * **New Features** * Global keyboard shortcuts added for navigating major app sections (home, editors, database, auth, storage, functions, realtime, advisors, observability, logs, integrations, settings). * Navigation items display their keybinds in hover tooltips and can be triggered via those shortcuts. * Tooltip timing adapts to sidebar state (immediate when collapsed, delayed when expanded). * Shortcut-aware tooltip display now shows multi-step key sequences clearly. --- apps/studio/components/interfaces/Sidebar.tsx | 50 +++++-- .../NavigationBar/NavigationBar.utils.tsx | 13 ++ apps/studio/components/ui/Shortcut.tsx | 77 ++++++++++ apps/studio/components/ui/ShortcutTooltip.tsx | 67 +++++++++ apps/studio/components/ui/ui.types.ts | 8 ++ apps/studio/state/shortcuts/registry.ts | 133 ++++++++++++++++++ .../ui/src/components/shadcn/ui/tooltip.tsx | 2 + 7 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 apps/studio/components/ui/Shortcut.tsx create mode 100644 apps/studio/components/ui/ShortcutTooltip.tsx diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index b97f2fd143c..0219209f0c1 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -27,6 +27,7 @@ import { useSidebar, } from 'ui' +import { Shortcut } from '../ui/Shortcut' import { Route } from '../ui/ui.types' import { useIsPlatformWebhooksEnabled, @@ -47,6 +48,7 @@ import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useAppStateSnapshot } from '@/state/app-state' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' export const ICON_SIZE = 32 export const ICON_STROKE_WIDTH = 1.5 @@ -167,14 +169,23 @@ export function SideBarNavLink({ active?: boolean onClick?: () => void } & ComponentPropsWithoutRef) { + const router = useRouter() + const { state: sidebarState } = useSidebar() const [sidebarBehaviour] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.SIDEBAR_BEHAVIOR, DEFAULT_SIDEBAR_BEHAVIOR ) + const isActiveLink = !!(route.link && !route.disabled) + const hasShortcut = !!(route.shortcutId && isActiveLink) + + // Collapsed: show immediately (replaces the old label-only tooltip + // that used to surface the name of an icon-only item). Expanded: + // slight delay so the tooltip doesn't flash while skimming the nav. + const shortcutPopoverDelay = sidebarState === 'collapsed' ? 0 : 1000 + const buttonProps = { disabled: route.disabled, - tooltip: sidebarBehaviour === 'closed' ? route.label : '', isActive: active, className: cn('text-sm', sidebarBehaviour === 'open' ? '!px-2' : ''), size: 'default' as const, @@ -190,14 +201,27 @@ export function SideBarNavLink({ ) + const button = isActiveLink ? ( + + {content} + + ) : ( + {content} + ) + return ( - {route.link && !route.disabled ? ( - - {content} - + {hasShortcut ? ( + router.push(route.link!)} + side="right" + delayDuration={shortcutPopoverDelay} + > + {button} + ) : ( - {content} + button )} ) @@ -218,13 +242,9 @@ const ProjectLinks = () => { const router = useRouter() const { ref } = useParams() const { data: project } = useSelectedProjectQuery() - const { data: org } = useSelectedOrganizationQuery() - const snap = useAppStateSnapshot() const { securityLints, errorLints } = useLints() const showReports = useIsFeatureEnabled('reports:all') - const { mutate: sendEvent } = useSendEventMutation() - const platformWebhooksEnabled = useIsPlatformWebhooksEnabled() const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() const activeRoute = router.pathname.split('/')[3] @@ -269,6 +289,7 @@ const ProjectLinks = () => { icon: , link: `/project/${ref}`, linkElement: , + shortcutId: SHORTCUT_IDS.NAV_HOME, }} /> {toolRoutes.map((route, i) => ( @@ -291,7 +312,7 @@ const ProjectLinks = () => { - {otherRoutes.map((route, i) => { + {otherRoutes.map((route) => { if (route.key === 'advisors') { return (
@@ -354,24 +375,28 @@ const OrganizationLinks = () => { href: `/org/${organizationSlug}`, key: 'projects', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_PROJECTS, }, { label: 'Team', href: `/org/${organizationSlug}/team`, key: 'team', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_TEAM, }, { label: 'Integrations', href: `/org/${organizationSlug}/integrations`, key: 'integrations', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_INTEGRATIONS, }, { label: 'Usage', href: `/org/${organizationSlug}/usage`, key: 'usage', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_USAGE, }, ...(showBilling ? [ @@ -380,6 +405,7 @@ const OrganizationLinks = () => { href: `/org/${organizationSlug}/billing`, key: 'billing', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_BILLING, }, ] : []), @@ -388,6 +414,7 @@ const OrganizationLinks = () => { href: `/org/${organizationSlug}/general`, key: 'settings', icon: , + shortcutId: SHORTCUT_IDS.NAV_ORG_SETTINGS, }, ] @@ -412,6 +439,7 @@ const OrganizationLinks = () => { key: item.label, icon: item.icon, disabled: disableAccessMfa, + shortcutId: item.shortcutId, }} /> ))} diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx index 5925d256e55..63725b7570d 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx @@ -6,6 +6,7 @@ import type { Route } from '@/components/ui/ui.types' import { EditorIndexPageLink } from '@/data/prefetchers/project.$ref.editor' import type { Project } from '@/data/projects/project-detail-query' import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' interface RouteContext { ref?: string @@ -52,6 +53,7 @@ export const generateToolRoutes = (ref?: string, project?: Project): Route[] => icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/editor`), linkElement: , + shortcutId: SHORTCUT_IDS.NAV_TABLE_EDITOR, }, { key: 'sql', @@ -59,6 +61,7 @@ export const generateToolRoutes = (ref?: string, project?: Project): Route[] => disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql`), + shortcutId: SHORTCUT_IDS.NAV_SQL_EDITOR, }, ] } @@ -89,6 +92,7 @@ export const generateProductRoutes = ( : isProjectActive ? `/project/${ref}/database/schemas` : `/project/${ref}/database/backups/scheduled`), + shortcutId: SHORTCUT_IDS.NAV_DATABASE, }, ...(authEnabled ? [ @@ -104,6 +108,7 @@ export const generateProductRoutes = ( : authOverviewPageEnabled ? `/project/${ref}/auth/overview` : `/project/${ref}/auth/users`), + shortcutId: SHORTCUT_IDS.NAV_AUTH, }, ] : []), @@ -115,6 +120,7 @@ export const generateProductRoutes = ( disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/storage/files`), + shortcutId: SHORTCUT_IDS.NAV_STORAGE, }, ] : []), @@ -126,6 +132,7 @@ export const generateProductRoutes = ( disabled: false, icon: , link: ref && `/project/${ref}/functions`, + shortcutId: SHORTCUT_IDS.NAV_FUNCTIONS, }, ] : []), @@ -137,6 +144,7 @@ export const generateProductRoutes = ( disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/realtime/inspector`), + shortcutId: SHORTCUT_IDS.NAV_REALTIME, }, ] : []), @@ -160,6 +168,7 @@ export const generateOtherRoutes = ( disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/advisors/security`), + shortcutId: SHORTCUT_IDS.NAV_ADVISORS, }, // Observability is only available on the platform, not for self-hosted/CLI ...(isPlatform && reportsEnabled @@ -170,6 +179,7 @@ export const generateOtherRoutes = ( disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/observability`), + shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY, }, ] : []), @@ -179,6 +189,7 @@ export const generateOtherRoutes = ( disabled: false, icon: , link: ref && (unifiedLogsEnabled ? `/project/${ref}/logs` : `/project/${ref}/logs/explorer`), + shortcutId: SHORTCUT_IDS.NAV_LOGS, }, { key: 'integrations', @@ -186,6 +197,7 @@ export const generateOtherRoutes = ( disabled: !isProjectActive, icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations`), + shortcutId: SHORTCUT_IDS.NAV_INTEGRATIONS, }, ] } @@ -202,6 +214,7 @@ export const generateSettingsRoutes = (ref?: string, features?: SettingsFeatures ref && (isPlatform ? `/project/${ref}/settings/general` : `/project/${ref}/settings/log-drains`), disabled: false, + shortcutId: SHORTCUT_IDS.NAV_SETTINGS, }, ] } diff --git a/apps/studio/components/ui/Shortcut.tsx b/apps/studio/components/ui/Shortcut.tsx new file mode 100644 index 00000000000..62d4ce2853e --- /dev/null +++ b/apps/studio/components/ui/Shortcut.tsx @@ -0,0 +1,77 @@ +import { TooltipContentProps } from '@ui/components/shadcn/ui/tooltip' +import type { ReactNode } from 'react' + +import { ShortcutTooltip } from './ShortcutTooltip' +import type { ShortcutId } from '@/state/shortcuts/registry' +import type { ShortcutOptions } from '@/state/shortcuts/types' +import { useShortcut } from '@/state/shortcuts/useShortcut' + +interface ShortcutProps { + /** Registered shortcut id — drives both the hotkey binding and the tooltip. */ + id: ShortcutId + /** Fires on the hotkey. Usually the same handler wired to the child's `onClick`. */ + onTrigger: () => void + /** Element to bind the shortcut to and wrap in the tooltip. */ + children: ReactNode + /** Per-mount overrides for the shortcut — see `ShortcutOptions`. */ + options?: ShortcutOptions + side?: TooltipContentProps['side'] + align?: TooltipContentProps['align'] + sideOffset?: number + delayDuration?: number + /** + * Override the label from the registry. Use when the wrapped element's + * action is a narrower/contextual variant of the registered shortcut. + */ + label?: string +} + +/** + * Bind a registered shortcut to an element AND show its keybind on hover, + * Linear-style. Single source of truth: one `id` drives both the hotkey + * listener and the tooltip, so they can't drift. + * + * The wrapped child stays fully interactive — Radix `asChild` passes clicks, + * focus, and refs through untouched. + * + * @example + * + * + * + * + * @example + * // Gate the hotkey on local state; tooltip still renders: + * + * + * + */ +export const Shortcut = ({ + id, + onTrigger, + children, + options, + side, + align, + sideOffset, + delayDuration, + label, +}: ShortcutProps) => { + useShortcut(id, onTrigger, options) + + return ( + + {children} + + ) +} diff --git a/apps/studio/components/ui/ShortcutTooltip.tsx b/apps/studio/components/ui/ShortcutTooltip.tsx new file mode 100644 index 00000000000..13bd66072d6 --- /dev/null +++ b/apps/studio/components/ui/ShortcutTooltip.tsx @@ -0,0 +1,67 @@ +import { TooltipContentProps } from '@ui/components/shadcn/ui/tooltip' +import { Fragment, type ReactNode } from 'react' +import { KeyboardShortcut, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' +import { SHORTCUT_DEFINITIONS, type ShortcutId } from '@/state/shortcuts/registry' + +interface ShortcutTooltipProps { + shortcutId: ShortcutId + children: ReactNode + side?: TooltipContentProps['side'] + align?: TooltipContentProps['align'] + sideOffset?: number + delayDuration?: number + /** + * Override the label from the registry. Use when the wrapped element's + * action is a narrower/contextual variant of the registered shortcut. + */ + label?: string +} + +/** + * Wraps any element to show its bound keyboard shortcut on hover/focus, in the + * style of Linear's shortcut tooltips: `"
) } + +function PreviousResultsCta() { + return ( + + + +
+

+ Previous Report +

+

See last year's report.

+
+ +
+ +
+ + ) +} diff --git a/apps/www/pages/state-of-startups-2025.tsx b/apps/www/pages/state-of-startups-2025.tsx index 3c6b5ac6aa3..a0f9d102b1e 100644 --- a/apps/www/pages/state-of-startups-2025.tsx +++ b/apps/www/pages/state-of-startups-2025.tsx @@ -174,10 +174,11 @@ function StateOfStartupsPage() { Date: Thu, 23 Apr 2026 11:40:43 -0600 Subject: [PATCH 23/28] fix(studio): preserve ignoreInputs across re-renders in useShortcut (#45174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes [FE-3060](https://linear.app/supabase/issue/FE-3060/modshiftc-modshiftm-shortcuts-suppressed-when-a-table-editor-cell-is). `Mod+Shift+C` (Copy as CSV) and `Mod+Shift+M` (Copy as Markdown) — and any other Meta/Ctrl/Escape shortcut registered via `useShortcut` without an explicit `ignoreInputs` — stopped firing when focus landed on a `react-data-grid` cell in the table editor (or any other input/contenteditable). ## Fix In `apps/studio/state/shortcuts/useShortcut.tsx`, only include `ignoreInputs` in the options object when it's actually set, so TanStack's register-time default sticks across re-renders. Updated the `ignoreInputs resolution` test to assert that the key is omitted (rather than passed as `undefined`) when neither caller nor registry set it. ## Test plan - [x] SQL editor results: `Cmd+Shift+C`, `Cmd+Shift+M`, `Cmd+Shift+J`, `Cmd+Shift+D` still fire - [x] `Mod+ArrowUp` / `Mod+ArrowDown` / `Mod+ArrowLeft` / `Mod+ArrowRight` inside a focused cell editor still blocked (registry sets `ignoreInputs: true`) - [x] Unit tests: `pnpm vitest run state/shortcuts/useShortcut.test.tsx` ## Summary by CodeRabbit * **Bug Fixes** * Fixed keyboard shortcut handling to properly respect the hotkey library's default configuration values when custom options are not explicitly specified. --- apps/studio/state/shortcuts/useShortcut.test.tsx | 5 +++-- apps/studio/state/shortcuts/useShortcut.tsx | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/studio/state/shortcuts/useShortcut.test.tsx b/apps/studio/state/shortcuts/useShortcut.test.tsx index 101ffb00ba8..9275dd10bf8 100644 --- a/apps/studio/state/shortcuts/useShortcut.test.tsx +++ b/apps/studio/state/shortcuts/useShortcut.test.tsx @@ -118,9 +118,10 @@ describe('useShortcut', () => { }) describe('ignoreInputs resolution', () => { - it('defaults to undefined when no registry default and no caller override', () => { + it('omits the key when no registry default and no caller override (library applies its per-hotkey default)', () => { renderHook(() => useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, vi.fn())) - expect(getLastHotkeyOptions().ignoreInputs).toBeUndefined() + const options = getLastHotkeyOptions() + expect('ignoreInputs' in options).toBe(false) }) it('uses the registry default when no caller override', () => { diff --git a/apps/studio/state/shortcuts/useShortcut.tsx b/apps/studio/state/shortcuts/useShortcut.tsx index 8b29baaf8fe..d5aad427798 100644 --- a/apps/studio/state/shortcuts/useShortcut.tsx +++ b/apps/studio/state/shortcuts/useShortcut.tsx @@ -60,7 +60,16 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor const timeout = options?.timeout ?? def.options?.timeout ?? undefined const ignoreInputs = options?.ignoreInputs ?? def.options?.ignoreInputs - useHotkeySequence(def.sequence, callback, { enabled, timeout, ignoreInputs }) + // Only include `ignoreInputs` when set. The library resolves it to a concrete + // boolean at register time (false for Meta/Ctrl/Escape, true otherwise), but + // its setOptions does an object spread on every re-render — passing + // `ignoreInputs: undefined` would overwrite the resolved value and re-enable + // the input-focus guard for shortcuts that should always fire. + useHotkeySequence(def.sequence, callback, { + enabled, + timeout, + ...(ignoreInputs !== undefined && { ignoreInputs }), + }) // Handle overrides for command menu const enabledInCommandMenu = enabled && (options?.registerInCommandMenu ?? false) From 8454ec241d0c6a8e073957698b53f6ad714f4e48 Mon Sep 17 00:00:00 2001 From: Samir Ketema <6003000+samirketema@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:02:08 -0700 Subject: [PATCH 24/28] feat: add batch email org invites (#44832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature ## What is the current behavior? When sending organization invites to multiple emails at once, the invitations API is called once for each email passed, passing a single email address in the `email` field. ## What is the new behavior? A single request is used when sending multiple organization invites at once, by using the new `emails` field. ## Additional context This builds further on https://github.com/supabase/supabase/pull/42637 ⚠️ Note: I'd like to merge this after getting the API changes in first: https://github.com/supabase/platform/pull/31561 ## Summary by CodeRabbit * **New Features** * Bulk invite: paste comma-separated emails (parsed, trimmed, deduplicated, lowercased) and send as a single batched request; inputs are categorized into new, already-invited, and existing members. * SSO and project scope options included in invite payloads. * **Bug Fixes / API** * Invitation endpoint now accepts multiple emails; resend uses multi-email format. Invalid addresses are blocked, existing members are skipped with error toasts, and overall success is reported with the dialog closing after invite. * **Tests** * Added unit and UI tests covering parsing, categorization, payload building, validation limits, and invite flows. --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com> --- .../TeamSettings/InviteMemberButton.tsx | 143 ++++------ .../TeamSettings/InviteMemberButton.utils.ts | 109 ++++++++ .../TeamSettings/MemberActions.tsx | 4 +- ...organization-invitation-create-mutation.ts | 8 +- .../TeamSettings/InviteMemberButton.test.tsx | 254 +++++++++++++++++ .../InviteMemberButton.utils.test.ts | 261 ++++++++++++++++++ 6 files changed, 687 insertions(+), 92 deletions(-) create mode 100644 apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils.ts create mode 100644 apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx create mode 100644 apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.utils.test.ts diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 0a523935d02..842910ae5ee 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -31,6 +31,14 @@ import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' +import { + BatchInvitationResult, + buildProjectPayload, + buildSsoPayload, + categorizeInviteEmails, + emailSchema, + parseEmails, +} from './InviteMemberButton.utils' import { useGetRolesManagementPermissions } from './TeamSettings.utils' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -50,13 +58,6 @@ import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose' import { DOCS_URL } from '@/lib/constants' import { useProfile } from '@/lib/profile' -function parseEmails(value: string): string[] { - return value - .split(',') - .map((e) => e.trim()) - .filter(Boolean) -} - export const InviteMemberButton = () => { const { slug } = useParams() const { profile } = useProfile() @@ -115,33 +116,23 @@ export const InviteMemberButton = () => { const { mutateAsync: inviteMemberAsync, isPending: isInviting } = useOrganizationCreateInvitationMutation() - const emailSchema = z - .string() - .min(1, 'At least one email address is required') - .refine( - (val) => { - const emails = parseEmails(val) - if (emails.length === 0) return false - return emails.every((e) => z.string().email().safeParse(e).success) - }, - (val) => { - const emails = parseEmails(val) - const invalid = emails.find((e) => !z.string().email().safeParse(e).success) - return { - message: invalid - ? `Invalid email address: ${invalid}` - : 'At least one email address is required', - } + const FormSchema = z + .object({ + email: emailSchema, + role: z.string().min(1, 'Role is required'), + applyToOrg: z.boolean(), + projectRef: z.string(), + requireSso: z.enum(['auto', 'sso', 'non-sso']), + }) + .superRefine((data, ctx) => { + if (!data.applyToOrg && !data.projectRef) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'A project must be selected', + path: ['projectRef'], + }) } - ) - - const FormSchema = z.object({ - email: emailSchema, - role: z.string().min(1, 'Role is required'), - applyToOrg: z.boolean(), - projectRef: z.string(), - requireSso: z.enum(['auto', 'sso', 'non-sso']), - }) + }) const form = useForm>({ mode: 'onSubmit', @@ -159,22 +150,10 @@ export const InviteMemberButton = () => { if (profile?.id === undefined) return console.error('Profile ID required') const emails = parseEmails(values.email).map((e) => e.toLowerCase()) - const alreadyInvited: string[] = [] - const alreadyMembers: string[] = [] - const toInvite: string[] = [] - - for (const emailAddress of emails) { - const existingMember = (members ?? []).find((member) => member.primary_email === emailAddress) - if (existingMember !== undefined) { - if (existingMember.invited_id) { - alreadyInvited.push(emailAddress) - } else { - alreadyMembers.push(emailAddress) - } - } else { - toInvite.push(emailAddress) - } - } + const { alreadyInvited, alreadyMembers, toInvite } = categorizeInviteEmails( + emails, + members ?? [] + ) if (alreadyInvited.length > 0) { toast.error( @@ -194,47 +173,39 @@ export const InviteMemberButton = () => { if (toInvite.length === 0) return } - const projectPayload = - !values.applyToOrg && values.projectRef ? { projects: [values.projectRef] } : {} + const projectPayload = buildProjectPayload(values.applyToOrg, values.projectRef) + const ssoPayload = buildSsoPayload(values.requireSso) - // Transform SSO preference to backend format - const ssoPayload = - values.requireSso === 'sso' - ? { requireSso: true } - : values.requireSso === 'non-sso' - ? { requireSso: false } - : {} // 'auto' - let backend use automatic behavior - - const results = await Promise.allSettled( - toInvite.map((emailAddress) => - inviteMemberAsync({ - slug, - email: emailAddress, - roleId: Number(values.role), - ...projectPayload, - ...ssoPayload, - }) - ) - ) - - const successCount = results.filter((r) => r.status === 'fulfilled').length - const failedEmails = toInvite.filter((_, i) => results[i].status === 'rejected') - - if (successCount > 0) { - toast.success( - successCount === 1 - ? 'Successfully sent invitation to new member' - : `Successfully sent invitations to ${successCount} new members` - ) - closeInviteDialog() + let result: BatchInvitationResult + try { + result = (await inviteMemberAsync({ + slug, + emails: toInvite, + roleId: Number(values.role), + ...projectPayload, + ...ssoPayload, + })) as BatchInvitationResult + } catch { + return // onError callback already showed the toast } - if (failedEmails.length > 0) { - toast.error( - failedEmails.length === 1 - ? `Failed to send invitation to ${failedEmails[0]}` - : `Failed to send invitations to ${failedEmails.length} emails` + + const { succeeded, failed } = result + + if (succeeded.length > 0) { + toast.success( + succeeded.length === 1 + ? 'Successfully sent invitation to new member' + : `Successfully sent invitations to ${succeeded.length} new members` ) } + + for (const { email, error } of failed) { + toast.error(`Failed to invite ${email}: ${error}`) + } + + if (succeeded.length > 0) { + closeInviteDialog() + } } useEffect(() => { diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils.ts b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils.ts new file mode 100644 index 00000000000..a58dc5be922 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils.ts @@ -0,0 +1,109 @@ +import * as z from 'zod' + +import type { OrganizationMember } from '@/data/organizations/organization-members-query' + +export const MAX_BATCH_INVITE_SIZE = 50 + +/** Max characters to show when an invalid token is long (e.g. comma-less paste of many addresses). */ +const MAX_INVALID_EMAIL_SNIPPET_LENGTH = 120 + +function formatInvalidEmailSnippet(token: string): string { + if (token.length <= MAX_INVALID_EMAIL_SNIPPET_LENGTH) return token + return `${token.slice(0, MAX_INVALID_EMAIL_SNIPPET_LENGTH)}…` +} + +export const emailSchema = z + .string() + .min(1, 'At least one email address is required') + .refine( + (val) => { + const emails = parseEmails(val) + if (emails.length === 0) return false + return emails.every((e) => z.string().email().safeParse(e).success) + }, + (val) => { + const emails = parseEmails(val) + const invalid = emails.find((e) => !z.string().email().safeParse(e).success) + return { + message: invalid + ? `Invalid email address: "${formatInvalidEmailSnippet(invalid)}"` + : 'At least one email address is required', + } + } + ) + .refine( + (val) => parseEmails(val).length <= MAX_BATCH_INVITE_SIZE, + (val) => { + const count = parseEmails(val).length + return { + message: `You can invite up to ${MAX_BATCH_INVITE_SIZE} members at a time. Remove ${count - MAX_BATCH_INVITE_SIZE} email ${count - MAX_BATCH_INVITE_SIZE === 1 ? 'address' : 'addresses'} to continue.`, + } + } + ) + +export function parseEmails(value: string): string[] { + const emails = value + .split(/[\s,]+/) + .map((e) => e.trim().toLowerCase()) + .filter(Boolean) + return [...new Set(emails)] +} + +export type CategorizedEmails = { + alreadyInvited: string[] + alreadyMembers: string[] + toInvite: string[] +} + +export type BatchInvitationFailure = { + email: string + error: string +} + +export type BatchInvitationResult = { + succeeded: string[] + failed: BatchInvitationFailure[] +} + +export function categorizeInviteEmails( + emails: string[], + members: OrganizationMember[] +): CategorizedEmails { + const alreadyInvited: string[] = [] + const alreadyMembers: string[] = [] + const toInvite: string[] = [] + + for (const email of emails) { + const existingMember = members.find((m) => m.primary_email === email) + if (existingMember !== undefined) { + if (existingMember.invited_id) { + alreadyInvited.push(email) + } else { + alreadyMembers.push(email) + } + } else { + toInvite.push(email) + } + } + + return { alreadyInvited, alreadyMembers, toInvite } +} + +export function buildProjectPayload( + applyToOrg: boolean, + projectRef: string +): { projects: string[] } | Record { + if (applyToOrg) return {} + if (!projectRef) { + throw new Error('projectRef is required when applyToOrg is false') + } + return { projects: [projectRef] } +} + +export function buildSsoPayload( + requireSso: 'auto' | 'sso' | 'non-sso' +): { requireSso: boolean } | Record { + if (requireSso === 'sso') return { requireSso: true } + if (requireSso === 'non-sso') return { requireSso: false } + return {} +} diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx index bffc2334788..6e32afd562a 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberActions.tsx @@ -128,12 +128,12 @@ export const MemberActions = ({ member }: MemberActionsProps) => { const projects = projectScopedRole.projects.map(({ ref }) => ref) inviteMember({ slug, - email: member.primary_email, + emails: [member.primary_email], roleId: projectScopedRole.base_role_id, projects, }) } else { - inviteMember({ slug, email: member.primary_email, roleId }) + inviteMember({ slug, emails: [member.primary_email], roleId }) } }, } diff --git a/apps/studio/data/organization-members/organization-invitation-create-mutation.ts b/apps/studio/data/organization-members/organization-invitation-create-mutation.ts index f28af34d4a0..ea632dac7e3 100644 --- a/apps/studio/data/organization-members/organization-invitation-create-mutation.ts +++ b/apps/studio/data/organization-members/organization-invitation-create-mutation.ts @@ -9,7 +9,7 @@ import type { ResponseError, UseCustomMutationOptions } from '@/types' export type OrganizationCreateInvitationVariables = { slug: string - email: string + emails: string[] roleId: number projects?: string[] requireSso?: boolean @@ -17,12 +17,12 @@ export type OrganizationCreateInvitationVariables = { export async function createOrganizationInvitation({ slug, - email, + emails, roleId, projects, requireSso, }: OrganizationCreateInvitationVariables) { - const payload: components['schemas']['CreateInvitationBody'] = { email, role_id: roleId } + const payload: components['schemas']['CreateInvitationBody'] = { emails, role_id: roleId } if (projects !== undefined) payload.role_scoped_projects = projects if (requireSso !== undefined) payload.require_sso = requireSso @@ -69,7 +69,7 @@ export const useOrganizationCreateInvitationMutation = ({ }, async onError(data, variables, context) { if (onError === undefined) { - toast.error(`Failed to update member role: ${data.message}`) + toast.error(`Failed to send invitation${data.message ? ': ' + data.message : ''}`) } else { onError(data, variables, context) } diff --git a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx new file mode 100644 index 00000000000..2420b822bef --- /dev/null +++ b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx @@ -0,0 +1,254 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { toast } from 'sonner' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { InviteMemberButton } from '@/components/interfaces/Organization/TeamSettings/InviteMemberButton' +import { customRender } from '@/tests/lib/custom-render' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock('common', async (importOriginal) => { + const actual = (await importOriginal()) as typeof import('common') + return { ...actual, useParams: () => ({ slug: 'test-org' }) } +}) + +vi.mock('@/lib/profile', () => ({ + useProfile: () => ({ profile: { id: 1, gotrue_id: 'user-1' } }), +})) + +vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ + useSelectedOrganizationQuery: () => ({ + data: { id: 1, slug: 'test-org', name: 'Test Org' }, + }), +})) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useGetPermissions: () => ({ permissions: [], organizationSlug: 'test-org' }), + doPermissionsCheck: () => true, + useAsyncCheckPermissions: () => ({ can: true, isSuccess: true }), +})) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: () => ({ organizationMembersCreate: true }), +})) + +vi.mock('@/data/organizations/organization-members-query', () => ({ + useOrganizationMembersQuery: () => ({ + data: [ + { + gotrue_id: 'user-1', + primary_email: 'me@example.com', + role_ids: [1], + }, + { + gotrue_id: 'existing-user', + primary_email: 'existing@example.com', + role_ids: [1], + }, + ], + }), +})) + +const mockRoles = { + org_scoped_roles: [{ id: 1, name: 'Developer', description: null }], +} +vi.mock('@/data/organization-members/organization-roles-query', () => ({ + useOrganizationRolesV2Query: () => ({ data: mockRoles, isSuccess: true }), +})) + +vi.mock('@/data/sso/sso-config-query', () => ({ + useOrgSSOConfigQuery: () => ({ data: null }), +})) + +vi.mock('@/data/subscriptions/org-subscription-query', () => ({ + useHasAccessToProjectLevelPermissions: () => false, +})) + +vi.mock('@/hooks/misc/useCheckEntitlements', () => ({ + useCheckEntitlements: () => ({ hasAccess: false }), +})) + +vi.mock('@/components/interfaces/Organization/TeamSettings/TeamSettings.utils', () => ({ + useGetRolesManagementPermissions: () => ({ rolesAddable: [1], rolesRemovable: [1] }), +})) + +const mockInvite = vi.fn().mockResolvedValue({ succeeded: [], failed: [] }) +vi.mock('@/data/organization-members/organization-invitation-create-mutation', () => ({ + useOrganizationCreateInvitationMutation: () => ({ + mutateAsync: mockInvite, + isPending: false, + }), +})) + +vi.mock('@/hooks/ui/useConfirmOnClose', () => ({ + useConfirmOnClose: ({ onClose }: { checkIsDirty: () => boolean; onClose: () => void }) => ({ + confirmOnClose: onClose, + handleOpenChange: (open: boolean) => { + if (!open) onClose() + }, + modalProps: { visible: false, onClose, onCancel: vi.fn() }, + }), +})) + +// Helpers +async function openDialog() { + await userEvent.click(screen.getByRole('button', { name: /invite members/i })) + return screen.findByRole('dialog') +} + +async function submitForm(emailValue: string) { + await openDialog() + fireEvent.change(screen.getByPlaceholderText(/name@example\.com/i), { + target: { value: emailValue }, + }) + fireEvent.click(screen.getByRole('button', { name: /send invitation/i })) +} + +// Tests +describe('InviteMemberButton', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInvite.mockResolvedValue({ succeeded: [], failed: [] }) + }) + + it('renders an enabled Invite members button', () => { + customRender() + expect(screen.getByRole('button', { name: /invite members/i })).toBeEnabled() + }) + + it('opens the invite dialog when the button is clicked', async () => { + customRender() + await openDialog() + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('Invite team members')).toBeInTheDocument() + }) + + it('calls the mutation with a single email in an array', async () => { + customRender() + await submitForm('new@example.com') + + await waitFor(() => { + expect(mockInvite).toHaveBeenCalledWith( + expect.objectContaining({ emails: ['new@example.com'] }) + ) + }) + }) + + it('calls the mutation with multiple emails parsed from a comma-separated input', async () => { + customRender() + await submitForm('alice@example.com, bob@example.com, carol@example.com') + + await waitFor(() => { + expect(mockInvite).toHaveBeenCalledWith( + expect.objectContaining({ + emails: ['alice@example.com', 'bob@example.com', 'carol@example.com'], + }) + ) + }) + }) + + it('lowercases emails before sending', async () => { + customRender() + await submitForm('User@Example.COM') + + await waitFor(() => { + expect(mockInvite).toHaveBeenCalledWith( + expect.objectContaining({ emails: ['user@example.com'] }) + ) + }) + }) + + it('shows a validation error for an invalid email', async () => { + customRender() + await openDialog() + fireEvent.change(screen.getByPlaceholderText(/name@example\.com/i), { + target: { value: 'not-an-email' }, + }) + fireEvent.click(screen.getByRole('button', { name: /send invitation/i })) + + expect(await screen.findByText(/invalid email address: "not-an-email"/i)).toBeInTheDocument() + expect(mockInvite).not.toHaveBeenCalled() + }) + + it('shows an error toast and skips the mutation for an already-existing member', async () => { + customRender() + await submitForm('existing@example.com') + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + 'existing@example.com is already in this organization' + ) + }) + expect(mockInvite).not.toHaveBeenCalled() + }) + + it('still invites new emails in a batch that also contains an existing member', async () => { + customRender() + await submitForm('new@example.com, existing@example.com') + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled() + expect(mockInvite).toHaveBeenCalledWith( + expect.objectContaining({ emails: ['new@example.com'] }) + ) + }) + }) + + it('shows a success toast for a single email in succeeded', async () => { + mockInvite.mockResolvedValueOnce({ succeeded: ['new@example.com'], failed: [] }) + customRender() + await submitForm('new@example.com') + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Successfully sent invitation to new member') + }) + }) + + it('shows a plural success toast when multiple emails succeeded', async () => { + mockInvite.mockResolvedValueOnce({ + succeeded: ['alice@example.com', 'bob@example.com'], + failed: [], + }) + customRender() + await submitForm('alice@example.com, bob@example.com') + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Successfully sent invitations to 2 new members') + }) + }) + + it('shows an error toast with the server error for each failed email', async () => { + mockInvite.mockResolvedValueOnce({ + succeeded: [], + failed: [{ email: 'new@example.com', error: 'Domain not allowed' }], + }) + customRender() + await submitForm('new@example.com') + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + 'Failed to invite new@example.com: Domain not allowed' + ) + }) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('shows both success and error toasts for a partial batch result', async () => { + mockInvite.mockResolvedValueOnce({ + succeeded: ['alice@example.com'], + failed: [{ email: 'bob@example.com', error: 'Domain not allowed' }], + }) + customRender() + await submitForm('alice@example.com, bob@example.com') + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Successfully sent invitation to new member') + expect(toast.error).toHaveBeenCalledWith( + 'Failed to invite bob@example.com: Domain not allowed' + ) + }) + }) +}) diff --git a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.utils.test.ts b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.utils.test.ts new file mode 100644 index 00000000000..3012b9a0209 --- /dev/null +++ b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.utils.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from 'vitest' + +import { + buildProjectPayload, + buildSsoPayload, + categorizeInviteEmails, + emailSchema, + MAX_BATCH_INVITE_SIZE, + parseEmails, +} from '@/components/interfaces/Organization/TeamSettings/InviteMemberButton.utils' +import type { OrganizationMember } from '@/data/organizations/organization-members-query' + +describe('parseEmails', () => { + test('parses a single email', () => { + expect(parseEmails('user@example.com')).toStrictEqual(['user@example.com']) + }) + + test('parses multiple comma-separated emails', () => { + expect(parseEmails('a@example.com,b@example.com,c@example.com')).toStrictEqual([ + 'a@example.com', + 'b@example.com', + 'c@example.com', + ]) + }) + + test('trims whitespace around each email', () => { + expect(parseEmails(' a@example.com , b@example.com ')).toStrictEqual([ + 'a@example.com', + 'b@example.com', + ]) + }) + + test('filters out empty entries from trailing, leading, or double commas', () => { + expect(parseEmails(',a@example.com,,b@example.com,')).toStrictEqual([ + 'a@example.com', + 'b@example.com', + ]) + }) + + test('removes duplicate email addresses', () => { + expect(parseEmails('a@example.com,a@example.com')).toStrictEqual(['a@example.com']) + }) + + test('returns an empty array for an empty string', () => { + expect(parseEmails('')).toStrictEqual([]) + }) + + test('returns an empty array for a whitespace-only string', () => { + expect(parseEmails(' ')).toStrictEqual([]) + }) + + test('returns an empty array for commas only', () => { + expect(parseEmails(',,,,')).toStrictEqual([]) + }) + + test('parses space-separated emails', () => { + expect(parseEmails('a@example.com b@example.com')).toStrictEqual([ + 'a@example.com', + 'b@example.com', + ]) + }) + + test('parses line breaks and mixed comma or space separators', () => { + expect(parseEmails('a@example.com\nb@example.com, c@example.com')).toStrictEqual([ + 'a@example.com', + 'b@example.com', + 'c@example.com', + ]) + }) +}) + +function makeMember(overrides: Partial = {}): OrganizationMember { + return { + gotrue_id: 'gotrue-1', + primary_email: 'member@example.com', + role_ids: [1], + username: 'member', + ...overrides, + } as OrganizationMember +} + +describe('categorizeInviteEmails', () => { + test('places a new email in toInvite when no members exist', () => { + expect(categorizeInviteEmails(['new@example.com'], [])).toStrictEqual({ + alreadyInvited: [], + alreadyMembers: [], + toInvite: ['new@example.com'], + }) + }) + + test('places an email in alreadyMembers when that member exists without an invited_id', () => { + const members = [makeMember({ primary_email: 'existing@example.com' })] + expect(categorizeInviteEmails(['existing@example.com'], members)).toStrictEqual({ + alreadyInvited: [], + alreadyMembers: ['existing@example.com'], + toInvite: [], + }) + }) + + test('places an email in alreadyInvited when that member has an invited_id', () => { + const members = [makeMember({ primary_email: 'invited@example.com', invited_id: 42 })] + expect(categorizeInviteEmails(['invited@example.com'], members)).toStrictEqual({ + alreadyInvited: ['invited@example.com'], + alreadyMembers: [], + toInvite: [], + }) + }) + + test('correctly categorizes a mixed batch', () => { + const members = [ + makeMember({ primary_email: 'member@example.com' }), + makeMember({ primary_email: 'invited@example.com', invited_id: 7 }), + ] + expect( + categorizeInviteEmails( + ['new@example.com', 'member@example.com', 'invited@example.com'], + members + ) + ).toStrictEqual({ + alreadyInvited: ['invited@example.com'], + alreadyMembers: ['member@example.com'], + toInvite: ['new@example.com'], + }) + }) + + test('places all emails in toInvite when none match existing members', () => { + const members = [makeMember({ primary_email: 'other@example.com' })] + expect( + categorizeInviteEmails(['a@example.com', 'b@example.com', 'c@example.com'], members) + ).toStrictEqual({ + alreadyInvited: [], + alreadyMembers: [], + toInvite: ['a@example.com', 'b@example.com', 'c@example.com'], + }) + }) + + test('returns all-empty for an empty email list', () => { + expect(categorizeInviteEmails([], [makeMember()])).toStrictEqual({ + alreadyInvited: [], + alreadyMembers: [], + toInvite: [], + }) + }) + + test('uses strict equality — does not match different casing', () => { + // The component lowercases emails before calling this function, + // so 'member@example.com' must NOT match 'Member@Example.com' + const members = [makeMember({ primary_email: 'Member@Example.com' })] + const result = categorizeInviteEmails(['member@example.com'], members) + expect(result.toInvite).toStrictEqual(['member@example.com']) + expect(result.alreadyMembers).toStrictEqual([]) + }) +}) + +describe('buildProjectPayload', () => { + test('returns empty object when applyToOrg is true', () => { + expect(buildProjectPayload(true, 'ref_abc')).toStrictEqual({}) + }) + + test('throws an error when applyToOrg is false but projectRef is empty', () => { + expect(() => buildProjectPayload(false, '')).toThrowError( + 'projectRef is required when applyToOrg is false' + ) + }) + + test('returns projects array when applyToOrg is false and projectRef is set', () => { + expect(buildProjectPayload(false, 'ref_abc')).toStrictEqual({ projects: ['ref_abc'] }) + }) + + test('wraps the projectRef in a single-element array', () => { + expect(buildProjectPayload(false, 'my-project-ref')).toStrictEqual({ + projects: ['my-project-ref'], + }) + }) +}) + +describe('buildSsoPayload', () => { + test('returns empty object for "auto"', () => { + expect(buildSsoPayload('auto')).toStrictEqual({}) + }) + + test('returns { requireSso: true } for "sso"', () => { + expect(buildSsoPayload('sso')).toStrictEqual({ requireSso: true }) + }) + + test('returns { requireSso: false } for "non-sso"', () => { + expect(buildSsoPayload('non-sso')).toStrictEqual({ requireSso: false }) + }) +}) + +function makeEmailList(count: number): string { + return Array.from({ length: count }, (_, i) => `user${i + 1}@example.com`).join(', ') +} + +function makeEmailListSpaceSeparated(count: number): string { + return Array.from({ length: count }, (_, i) => `user${i + 1}@example.com`).join(' ') +} + +describe('emailSchema', () => { + test('accepts a single valid email', () => { + expect(emailSchema.safeParse('user@example.com').success).toBe(true) + }) + + test('accepts exactly 50 emails', () => { + expect(emailSchema.safeParse(makeEmailList(MAX_BATCH_INVITE_SIZE)).success).toBe(true) + }) + + test('rejects 51 emails — singular "address" when exactly 1 needs removing', () => { + const result = emailSchema.safeParse(makeEmailList(51)) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'You can invite up to 50 members at a time. Remove 1 email address to continue.' + ) + } + }) + + test('rejects 51 space-separated emails (same as comma-separated batch limit)', () => { + const result = emailSchema.safeParse(makeEmailListSpaceSeparated(51)) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'You can invite up to 50 members at a time. Remove 1 email address to continue.' + ) + } + }) + + test('rejects 99 emails — plural "addresses" when more than 1 needs removing', () => { + const result = emailSchema.safeParse(makeEmailList(99)) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'You can invite up to 50 members at a time. Remove 49 email addresses to continue.' + ) + } + }) + + test('rejects an empty string', () => { + const result = emailSchema.safeParse('') + expect(result.success).toBe(false) + }) + + test('rejects an invalid email address and names it', () => { + const result = emailSchema.safeParse('notanemail') + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('Invalid email address: "notanemail"') + } + }) + + test('truncates a very long invalid token in the error message', () => { + const longToken = `${'x'.repeat(130)}@` + const result = emailSchema.safeParse(longToken) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message.startsWith('Invalid email address: "')).toBe(true) + expect(result.error.issues[0].message.endsWith('…"')).toBe(true) + expect(result.error.issues[0].message.length).toBeLessThan(longToken.length + 50) + } + }) +}) From 31689b1a717a04244ea5e3d2161d9a7bbd00d64d Mon Sep 17 00:00:00 2001 From: Tom Gallacher Date: Thu, 23 Apr 2026 20:43:35 +0100 Subject: [PATCH 25/28] chore: default staging to AWS provider (#45180) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? chore ## What is the current behavior? New project dialog defaults to AWS Revamped provider on staging. ## What is the new behavior? New project dialog defaults to AWS provider on staging. ## Additional context N/A ## Summary by CodeRabbit * **Bug Fixes** * Standardized the default cloud provider to AWS across all environments (staging/preview included), ensuring consistent infrastructure selection. --------- Co-authored-by: Ivan Vasilov --- apps/studio/lib/constants/infrastructure.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/studio/lib/constants/infrastructure.ts b/apps/studio/lib/constants/infrastructure.ts index 3ecb57d144e..9a0503fb7de 100644 --- a/apps/studio/lib/constants/infrastructure.ts +++ b/apps/studio/lib/constants/infrastructure.ts @@ -1,4 +1,3 @@ -import type { CloudProvider } from 'shared-data' import { AWS_REGIONS, FLY_REGIONS } from 'shared-data' import type { components } from '@/data/api' @@ -36,11 +35,7 @@ export const PRICING_TIER_PRODUCT_IDS = { } export function useDefaultProvider() { - const defaultProvider: CloudProvider = - process.env.NEXT_PUBLIC_ENVIRONMENT && - ['staging', 'preview'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT) - ? 'AWS_K8S' - : 'AWS' + const defaultProvider = 'AWS' const { infraCloudProviders: validCloudProviders } = useCustomContent(['infra:cloud_providers']) @@ -48,7 +43,7 @@ export function useDefaultProvider() { return defaultProvider } - return (validCloudProviders?.[0] ?? 'AWS') as CloudProvider + return validCloudProviders?.[0] ?? 'AWS' } export const PROVIDERS = { From 7739b7a540f079363291a4dda0b93ed867cf45ef Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Thu, 23 Apr 2026 16:49:31 -0300 Subject: [PATCH 26/28] fix: clear tax ID fields on billing country change (#45182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Mirrors the tax-ID-clearing behavior already present in `NewPaymentMethodElement` (used by `AddPaymentMethodForm`) over to `BillingCustomerDataForm`. When a user changes their billing country, the previously selected tax ID (type, value, name) is now cleared so a stale tax ID from the previous country doesn't silently persist under the new one. ### Test plan - [x]  Open **Organization Settings → Billing → Billing Address** for an org with a saved billing address + tax ID - [x]  Verify the saved tax ID is shown on initial load (not cleared) - [x]  Change the country in the address element to a different country - [x]  Verify the tax ID combobox resets to "Select tax ID" and the tax ID value input disappears - [x]  Change the country back to the original country → tax ID still cleared (does not re-populate) - [x]  Pick a new tax ID for the new country, enter a value, save → confirm it persists - [x]  Reload the page → saved tax ID shows correctly and is not cleared on mount ## Summary by CodeRabbit * **Bug Fixes** * Tax ID fields in organization billing settings are now automatically cleared when the billing address country is changed, preventing country-specific tax ID mismatches. --- .../BillingCustomerDataForm.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx index ac9341ef29b..a4a09a52903 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerDataForm.tsx @@ -5,7 +5,7 @@ import type { StripeAddressElementOptions, } from '@stripe/stripe-js' import { Check, ChevronsUpDown, Info, X } from 'lucide-react' -import { useId, useMemo, useState } from 'react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' import { UseFormReturn } from 'react-hook-form' import { Button, @@ -87,6 +87,23 @@ export const BillingCustomerDataForm = ({ ) }, [addressCountry]) + // Clear tax ID fields when the billing country changes so a stale tax ID + // from the previous country doesn't persist under the new one. + const prevCountryRef = useRef(addressCountry) + useEffect(() => { + if (!addressCountry) return + + const isCountryChange = + prevCountryRef.current !== undefined && prevCountryRef.current !== addressCountry + prevCountryRef.current = addressCountry + + if (isCountryChange) { + form.setValue('tax_id_type', '', { shouldDirty: true }) + form.setValue('tax_id_value', '', { shouldDirty: true }) + form.setValue('tax_id_name', '', { shouldDirty: true }) + } + }, [addressCountry, form]) + return (
From eddaded58dbdf56dd405411f862ec07007e240e0 Mon Sep 17 00:00:00 2001 From: Chris Chinchilla Date: Thu, 23 Apr 2026 22:02:06 +0100 Subject: [PATCH 27/28] docs: Auth hooks Keys changes (#45168) --- .../content/guides/auth/auth-hooks/before-user-created-hook.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/auth/auth-hooks/before-user-created-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/before-user-created-hook.mdx index 51c604071b6..df2b1199cda 100644 --- a/apps/docs/content/guides/auth/auth-hooks/before-user-created-hook.mdx +++ b/apps/docs/content/guides/auth/auth-hooks/before-user-created-hook.mdx @@ -727,7 +727,7 @@ import { createClient } from 'https://esm.sh/@supabase/supabase-js' const whSecret = Deno.env.get('BEFORE_USER_CREATED_HOOK_SECRET')?.replace('v1,whsec_', '') const supabaseUrl = Deno.env.get('SUPABASE_URL') -const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') +const supabaseKey = Deno.env.get('SUPABASE_SECRET_KEY') const wh = new Webhook(whSecret) const supabase = createClient(supabaseUrl, supabaseKey) From 1f318582e1d640f30cb1f95a8c320af53871c772 Mon Sep 17 00:00:00 2001 From: Sean Oliver <882952+seanoliver@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:32:02 -0700 Subject: [PATCH 28/28] fix(growth): preserve non-accept consent decisions on banner re-init (#45187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Cookie banner keeps re-prompting GDPR users who denied consent or made a partial opt-out via Privacy Settings — they can't get rid of it. Reported by Christian Gedde-Dahl (Front SU-362240, mygame.no) and a Supabase support engineer independently. The March fix for FE-2648 handled the accept case — users got their banner dismissal stomped when GTM's Usercentrics integration migrated localStorage from `uc_settings` to `ucData`/`ucString`. But that fix only recognized uniformly-accepted `ucData`, so any other shape (deny-all, essentials-plus-some-tracking, partial opt-out via the Privacy Settings modal) fell through and was treated as "no prior decision." Banner re-prompts on every page load. Christian's and his colleague's `ucData.consent.services` showed 13 services accepted (essentials + functional) and 4 tracking services denied — the shape you get from toggling off the Marketing category in Privacy Settings. Our detection ignored it. ## Changes - `detectPriorConsent` now returns a discriminated union — `null`, `{ kind: 'uniform-accept' }`, or `{ kind: 'decisions'; decisions }` — so we restore per-service state faithfully instead of flattening to deny-all. - Parse `uc_settings` for the fast cross-app nav case. The old fallback treated `uc_user_interaction === "true"` as uniform-accept, which silently upgraded deny users (GDPR violation waiting to happen). Now the flag is just a gate confirming the user actually interacted, and we read real decisions from `uc_settings.services[]`. - Extracted the post-init orchestration from `initUserCentrics` into exported `applyPriorDecisionToSDK(UC, initialUIValues, priorDecision)` so it's unit-testable without mocking the dynamic SDK import. - Added a coverage cross-check: if the Usercentrics ruleset has a non-essential service that isn't in the user's stored decisions, force a re-prompt rather than silently defaulting the new service. Essentials are skipped because the SDK forces them on regardless. - Everything fails closed on partial `ucData` / `uc_settings` corruption. Cherry-picking the valid subset would bias toward over-consent, which is the worst-direction bias in this domain. ## Testing 27 unit tests covering `detectPriorConsent` parsing (both `ucData` and `uc_settings` paths, fail-closed on malformed or partially-corrupt blobs, combined scenarios) and `applyPriorDecisionToSDK` orchestration (uniform accept, decisions with full coverage, uncovered-non-essential compliance guard, essentials-only negative control, null fallthrough, SDK-already-consented passthrough). Can't fully repro on staging — CSP blocks GTM on preview, so the `ucData` migration never fires. Same limitation as the original FE-2648 fix. Will verify in production post-merge by asking Christian to reload and watching support ticket volume. Reviewed twice by Codex with a full iteration between passes; final pass found no functional blockers. GROWTH-790 ## Summary by CodeRabbit * **Bug Fixes** * Fail-closed validation for stored consent data: malformed, partial, or missing entries now yield null and avoid unsafe restoration. * Improved precedence and fallback so corrupt prior data won’t incorrectly restore consent. * **Refactor** * Consent detection now returns richer prior-decision results (uniform accept, per-service decisions, or null). * Applying prior decisions to the SDK uses stricter coverage checks before restoring per-service consent. * **Tests** * Expanded tests covering varied stored-consent shapes, gating rules, precedence, recovery, and SDK application behavior. --- packages/common/consent-state.test.ts | 381 +++++++++++++++++++++++--- packages/common/consent-state.ts | 256 ++++++++++++----- 2 files changed, 539 insertions(+), 98 deletions(-) diff --git a/packages/common/consent-state.test.ts b/packages/common/consent-state.test.ts index 71a96d892e4..cedda69c4b0 100644 --- a/packages/common/consent-state.test.ts +++ b/packages/common/consent-state.test.ts @@ -1,7 +1,8 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { UserDecision } from '@usercentrics/cmp-browser-sdk' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { detectPriorConsent } from './consent-state' +import { applyPriorDecisionToSDK, consentState, detectPriorConsent } from './consent-state' // jsdom's localStorage can be flaky in vitest, so ensure it's available const storage = new Map() @@ -31,7 +32,7 @@ afterEach(() => { describe('detectPriorConsent', () => { describe('scenario 1: GTM compressed format (ucData)', () => { - it('returns true when ucData has all services consented', () => { + it('returns uniform-accept when every service has consent: true', () => { storage.set( 'ucData', JSON.stringify({ @@ -46,64 +47,218 @@ describe('detectPriorConsent', () => { }) ) - expect(detectPriorConsent()).toBe(true) + expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' }) }) - it('returns false when any service has consent: false', () => { + // Real customer data shape from GROWTH-790: essentials and functional + // services accepted, marketing/tracking services denied. This is what + // Opt out or a Privacy Settings toggle of the Marketing category produces + // in production — essentials are locked on and cannot actually be denied. + it('returns per-service decisions when ucData has a mixed consent state (GROWTH-790)', () => { + storage.set( + 'ucData', + JSON.stringify({ + gcm: { + adsDataRedaction: true, + adStorage: 'denied', + adPersonalization: 'denied', + adUserData: 'denied', + analyticsStorage: 'denied', + }, + consent: { + services: { + J39GyuWQq: { name: 'Amazon Web Services', consent: true }, + HkIVcNiuoZX: { name: 'Cloudflare', consent: true }, + H1PKqNodoWQ: { name: 'Google AJAX', consent: true }, + HkPBYFofN: { name: 'Google Fonts', consent: true }, + QjO6LaiOd: { name: 'hCaptcha', consent: true }, + 'F-REmjGq7': { name: 'JSDelivr', consent: true }, + Hko_qNsui_Q: { name: 'reCAPTCHA', consent: true }, + rH1vNPCFR: { name: 'Sentry', consent: true }, + ry3w9Vo_oZ7: { name: 'Stripe', consent: true }, + BJTzqNi_i_m: { name: 'Twitter Plugin', consent: true }, + HLap0udLC: { name: 'Twitter Syndication', consent: true }, + H1Vl5NidjWX: { name: 'Usercentrics Consent Management Platform', consent: true }, + BJz7qNsdj_7: { name: 'YouTube Video', consent: true }, + S1_9Vsuj_Q: { name: 'Google Ads', consent: false }, + HkocEodjb7: { name: 'Google Analytics', consent: false }, + BJ59EidsWQ: { name: 'Google Tag Manager', consent: false }, + nV4hGUA8RQK5Ez: { name: 'Supabase Event Tracking', consent: false }, + }, + }, + }) + ) + + const result = detectPriorConsent() + if (result === null || result.kind !== 'decisions') { + throw new Error(`expected decisions result, got ${JSON.stringify(result)}`) + } + expect(result.decisions).toHaveLength(17) + // Essentials / functional services preserved as accepted + expect(result.decisions).toContainEqual({ serviceId: 'J39GyuWQq', status: true }) + expect(result.decisions).toContainEqual({ serviceId: 'rH1vNPCFR', status: true }) + // Tracking services preserved as denied + expect(result.decisions).toContainEqual({ serviceId: 'S1_9Vsuj_Q', status: false }) + expect(result.decisions).toContainEqual({ serviceId: 'HkocEodjb7', status: false }) + expect(result.decisions).toContainEqual({ serviceId: 'BJ59EidsWQ', status: false }) + expect(result.decisions).toContainEqual({ serviceId: 'nV4hGUA8RQK5Ez', status: false }) + }) + + it('returns per-service decisions (all false) when every service was denied', () => { storage.set( 'ucData', JSON.stringify({ consent: { services: { - svc1: { name: 'Google Analytics', consent: true }, + svc1: { name: 'Google Analytics', consent: false }, svc2: { name: 'Stripe', consent: false }, }, }, }) ) - expect(detectPriorConsent()).toBe(false) + expect(detectPriorConsent()).toEqual({ + kind: 'decisions', + decisions: [ + { serviceId: 'svc1', status: false }, + { serviceId: 'svc2', status: false }, + ], + }) }) - it('returns false when services object is empty', () => { + it('returns null when services object is empty', () => { storage.set('ucData', JSON.stringify({ consent: { services: {} } })) - expect(detectPriorConsent()).toBe(false) + expect(detectPriorConsent()).toBeNull() }) - it('returns false when ucData is malformed JSON', () => { + it('returns null when ucData is malformed JSON', () => { storage.set('ucData', 'not-json') - expect(detectPriorConsent()).toBe(false) + expect(detectPriorConsent()).toBeNull() }) - it('returns false when ucData has no consent.services', () => { + it('returns null when ucData has no consent.services', () => { storage.set('ucData', JSON.stringify({ gcm: {} })) - expect(detectPriorConsent()).toBe(false) + expect(detectPriorConsent()).toBeNull() }) - it('returns false when services contains non-object values', () => { + it('returns null when every service value is non-object', () => { storage.set('ucData', JSON.stringify({ consent: { services: { svc1: 'not-an-object' } } })) - expect(detectPriorConsent()).toBe(false) + expect(detectPriorConsent()).toBeNull() + }) + + // Partial parse failures must fail closed. Cherry-picking the valid + // subset and calling it uniform-accept would let corrupted third-party + // storage silently upgrade a user's consent — the worst-direction bias + // in this domain. + it('returns null when any entry fails schema (fails closed on partial corruption)', () => { + storage.set( + 'ucData', + JSON.stringify({ + consent: { + services: { + svc1: 'not-an-object', + svc2: { name: 'Valid', consent: true }, + }, + }, + }) + ) + expect(detectPriorConsent()).toBeNull() }) }) - describe('scenario 2: fast cross-app navigation (uc_user_interaction)', () => { - it('returns true when uc_user_interaction is "true"', () => { + describe('scenario 2: fast cross-app navigation (uc_settings gated by uc_user_interaction)', () => { + const buildUcSettings = (services: Array<{ id: string; status: boolean }>) => + JSON.stringify({ + controllerId: 'test-controller', + id: 'test-settings', + language: 'en', + services: services.map((s) => ({ + id: s.id, + status: s.status, + processorId: 'test-processor', + history: [], + version: '1.0.0', + })), + version: '1.0.0', + }) + + it('returns uniform-accept when uc_settings has every service accepted', () => { storage.set('uc_user_interaction', 'true') - expect(detectPriorConsent()).toBe(true) + storage.set( + 'uc_settings', + buildUcSettings([ + { id: 'svc1', status: true }, + { id: 'svc2', status: true }, + ]) + ) + expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' }) }) - it('returns false when uc_user_interaction is "false"', () => { + it('returns per-service decisions when uc_settings has a mixed state', () => { + storage.set('uc_user_interaction', 'true') + storage.set( + 'uc_settings', + buildUcSettings([ + { id: 'essential1', status: true }, + { id: 'tracking1', status: false }, + { id: 'tracking2', status: false }, + ]) + ) + const result = detectPriorConsent() + if (result === null || result.kind !== 'decisions') { + throw new Error(`expected decisions result, got ${JSON.stringify(result)}`) + } + expect(result.decisions).toHaveLength(3) + expect(result.decisions).toContainEqual({ serviceId: 'essential1', status: true }) + expect(result.decisions).toContainEqual({ serviceId: 'tracking1', status: false }) + expect(result.decisions).toContainEqual({ serviceId: 'tracking2', status: false }) + }) + + it('returns null when uc_user_interaction is "true" but uc_settings is absent', () => { + storage.set('uc_user_interaction', 'true') + expect(detectPriorConsent()).toBeNull() + }) + + it('returns null when uc_settings is malformed JSON', () => { + storage.set('uc_user_interaction', 'true') + storage.set('uc_settings', 'not-json') + expect(detectPriorConsent()).toBeNull() + }) + + it('returns null when uc_settings services array is empty', () => { + storage.set('uc_user_interaction', 'true') + storage.set('uc_settings', buildUcSettings([])) + expect(detectPriorConsent()).toBeNull() + }) + + it('returns null when any uc_settings service entry fails schema', () => { + storage.set('uc_user_interaction', 'true') + storage.set( + 'uc_settings', + JSON.stringify({ + services: [ + { id: 'svc1', status: true, processorId: 'p', history: [], version: '1' }, + { id: 'svc2', status: 'not-a-boolean', processorId: 'p', history: [], version: '1' }, + ], + }) + ) + expect(detectPriorConsent()).toBeNull() + }) + + it('returns null when uc_user_interaction is "false" (even if uc_settings is valid)', () => { storage.set('uc_user_interaction', 'false') - expect(detectPriorConsent()).toBe(false) + storage.set('uc_settings', buildUcSettings([{ id: 'svc1', status: true }])) + expect(detectPriorConsent()).toBeNull() }) - it('returns false when uc_user_interaction is absent', () => { - expect(detectPriorConsent()).toBe(false) + it('returns null when uc_user_interaction is absent', () => { + storage.set('uc_settings', buildUcSettings([{ id: 'svc1', status: true }])) + expect(detectPriorConsent()).toBeNull() }) }) describe('combined scenarios', () => { - it('returns true when both ucData and uc_user_interaction indicate consent', () => { + it('returns uniform-accept when ucData is fully accepted (ignoring uc_user_interaction/uc_settings)', () => { storage.set( 'ucData', JSON.stringify({ @@ -111,27 +266,185 @@ describe('detectPriorConsent', () => { }) ) storage.set('uc_user_interaction', 'true') - expect(detectPriorConsent()).toBe(true) + expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' }) }) - it('returns true when ucData has consent but uc_user_interaction is false', () => { + it('prefers ucData decisions over uc_settings when both exist', () => { storage.set( 'ucData', JSON.stringify({ - consent: { services: { svc1: { name: 'GA', consent: true } } }, + consent: { services: { svc1: { name: 'GA', consent: false } } }, }) ) - storage.set('uc_user_interaction', 'false') - expect(detectPriorConsent()).toBe(true) - }) - - it('returns true when ucData is absent but uc_user_interaction is true', () => { storage.set('uc_user_interaction', 'true') - expect(detectPriorConsent()).toBe(true) + storage.set( + 'uc_settings', + JSON.stringify({ + services: [{ id: 'svc1', status: true, processorId: 'p', history: [], version: '1' }], + }) + ) + expect(detectPriorConsent()).toEqual({ + kind: 'decisions', + decisions: [{ serviceId: 'svc1', status: false }], + }) }) - it('returns false when localStorage is completely empty', () => { - expect(detectPriorConsent()).toBe(false) + it('falls back to uc_settings when ucData is corrupt (scenario 1 fails closed, scenario 2 recovers)', () => { + storage.set('ucData', JSON.stringify({ consent: { services: { svc1: 'corrupt' } } })) + storage.set('uc_user_interaction', 'true') + storage.set( + 'uc_settings', + JSON.stringify({ + services: [{ id: 'svc1', status: false, processorId: 'p', history: [], version: '1' }], + }) + ) + expect(detectPriorConsent()).toEqual({ + kind: 'decisions', + decisions: [{ serviceId: 'svc1', status: false }], + }) + }) + + it('returns null when localStorage is completely empty', () => { + expect(detectPriorConsent()).toBeNull() }) }) }) + +describe('applyPriorDecisionToSDK', () => { + type MockService = { id: string; isEssential: boolean } + + const makeMockUC = ( + opts: { + services?: MockService[] + areAllAccepted?: boolean + onAcceptAll?: () => Promise + onUpdateServices?: (decisions: UserDecision[]) => Promise + } = {} + ) => { + const services = opts.services ?? [] + return { + acceptAllServices: vi.fn(opts.onAcceptAll ?? (() => Promise.resolve())), + denyAllServices: vi.fn(() => Promise.resolve()), + updateServices: vi.fn(opts.onUpdateServices ?? (() => Promise.resolve())), + getCategoriesBaseInfo: vi.fn(() => []), + getServicesBaseInfo: vi.fn(() => services), + areAllConsentsAccepted: vi.fn(() => opts.areAllAccepted ?? false), + } + } + + beforeEach(() => { + consentState.UC = null + consentState.categories = null + consentState.showConsentToast = false + consentState.hasConsented = false + }) + + it('uniform-accept: calls acceptAllServices, sets hasConsented, suppresses banner', () => { + const UC = makeMockUC() + applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, { kind: 'uniform-accept' }) + + expect(UC.acceptAllServices).toHaveBeenCalledOnce() + expect(UC.updateServices).not.toHaveBeenCalled() + expect(consentState.hasConsented).toBe(true) + expect(consentState.showConsentToast).toBe(false) + }) + + it('decisions with full coverage: calls updateServices with stored decisions, suppresses banner', () => { + const UC = makeMockUC({ + services: [ + { id: 'essential1', isEssential: true }, + { id: 'tracking1', isEssential: false }, + { id: 'tracking2', isEssential: false }, + ], + }) + const decisions: UserDecision[] = [ + { serviceId: 'tracking1', status: true }, + { serviceId: 'tracking2', status: false }, + ] + + applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, { kind: 'decisions', decisions }) + + expect(UC.updateServices).toHaveBeenCalledWith(decisions) + expect(UC.acceptAllServices).not.toHaveBeenCalled() + expect(consentState.showConsentToast).toBe(false) + }) + + // GROWTH-790 compliance guard: when the ruleset adds a new non-essential + // service after the user's stored decisions were written, we must NOT + // silently restore — the user has never seen this service and can't have + // consented to it. Show the banner instead. + it('decisions with uncovered non-essential service: shows banner, does not mutate SDK', () => { + const UC = makeMockUC({ + services: [ + { id: 'tracking1', isEssential: false }, + { id: 'tracking_new', isEssential: false }, // in ruleset, not in decisions + ], + }) + + applyPriorDecisionToSDK( + UC as never, + { initialLayer: 0 }, + { kind: 'decisions', decisions: [{ serviceId: 'tracking1', status: false }] } + ) + + expect(UC.updateServices).not.toHaveBeenCalled() + expect(UC.acceptAllServices).not.toHaveBeenCalled() + expect(consentState.showConsentToast).toBe(true) + }) + + // Essentials are SDK-forced-on regardless of user decision, so a new + // essential service appearing since the stored decisions were written + // should not trigger a re-prompt — the user has nothing meaningful to + // decide about it. + it('decisions covering only non-essentials: still restores (essentials ignored in coverage check)', () => { + const UC = makeMockUC({ + services: [ + { id: 'essential_new', isEssential: true }, // not in decisions, but essential + { id: 'tracking1', isEssential: false }, + ], + }) + + applyPriorDecisionToSDK( + UC as never, + { initialLayer: 0 }, + { kind: 'decisions', decisions: [{ serviceId: 'tracking1', status: false }] } + ) + + expect(UC.updateServices).toHaveBeenCalledOnce() + expect(consentState.showConsentToast).toBe(false) + }) + + it('null prior decision: shows banner at default', () => { + const UC = makeMockUC() + applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, null) + + expect(UC.updateServices).not.toHaveBeenCalled() + expect(UC.acceptAllServices).not.toHaveBeenCalled() + expect(consentState.showConsentToast).toBe(true) + }) + + it('SDK not requesting first-layer banner: no restore attempted even if priorDecision exists', () => { + const UC = makeMockUC({ areAllAccepted: true }) + applyPriorDecisionToSDK(UC as never, { initialLayer: 1 }, { kind: 'uniform-accept' }) + + expect(UC.acceptAllServices).not.toHaveBeenCalled() + expect(UC.updateServices).not.toHaveBeenCalled() + expect(consentState.hasConsented).toBe(true) + expect(consentState.showConsentToast).toBe(false) + }) + + it('SDK already considers user consented: passes through, no restore', () => { + const UC = makeMockUC({ areAllAccepted: true }) + applyPriorDecisionToSDK( + UC as never, + { initialLayer: 0 }, + { + kind: 'decisions', + decisions: [{ serviceId: 'svc1', status: false }], + } + ) + + expect(UC.updateServices).not.toHaveBeenCalled() + expect(consentState.hasConsented).toBe(true) + }) +}) diff --git a/packages/common/consent-state.ts b/packages/common/consent-state.ts index c080efbb98f..99ffee7fea1 100644 --- a/packages/common/consent-state.ts +++ b/packages/common/consent-state.ts @@ -4,61 +4,133 @@ import { proxy, snapshot, useSnapshot } from 'valtio' import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from './constants' +export type PriorConsentDecision = + | null + | { kind: 'uniform-accept' } + | { kind: 'decisions'; decisions: UserDecision[] } + +type UcDataServiceEntry = [string, { consent: boolean }] + +const isValidUcDataServiceEntry = (entry: [string, unknown]): entry is UcDataServiceEntry => { + const value = entry[1] + return ( + typeof value === 'object' && + value !== null && + typeof (value as { consent: unknown }).consent === 'boolean' + ) +} + +type UcSettingsService = { id: string; status: boolean } + +const isValidUcSettingsService = (service: unknown): service is UcSettingsService => + typeof service === 'object' && + service !== null && + typeof (service as { id: unknown }).id === 'string' && + typeof (service as { status: unknown }).status === 'boolean' + /** - * Check if the user previously accepted all consent services by reading + * Check whether the user previously made a consent decision by reading * localStorage state that was written before UC.init() overwrites it. * - * Handles two scenarios (FE-2648): + * Returns enough information for the caller to restore the user's exact + * prior state via UC.updateServices, or to fast-path via UC.acceptAllServices + * when we can safely identify a uniform accept. Returns null when nothing + * trustworthy can be detected — in that case the caller should show the + * banner rather than fabricate a decision. + * + * Handles two scenarios (FE-2648 and GROWTH-790): * * 1. Slow navigation: GTM's Usercentrics integration replaced uc_settings with - * compressed ucString/ucData after acceptAllServices(). On the next page load, - * UC.init() can't read that format and treats the user as new. + * compressed ucString/ucData after the user's decision. On the next page + * load, UC.init() can't read that format and treats the user as new. We + * read ucData.consent.services directly and return every per-service + * decision so the caller can restore the user's exact state — including + * mixed states (essentials + functional accepted, tracking denied) that + * users produce via the Privacy Settings modal or the Opt out button. * - * 2. Fast navigation: User accepted on app A and navigated to app B before GTM - * finished writing ucData. App B's UC.init() overwrites uc_settings with a - * fresh controllerId and resets uc_user_interaction to false. We detect the - * prior uc_user_interaction: "true" before init stomps it. + * 2. Fast navigation: User decided on app A and navigated to app B before GTM + * finished writing ucData (or before GTM loaded at all, which happens on + * deny since TelemetryTagManager is gated behind hasAccepted). App B's + * UC.init() overwrites uc_settings with a fresh controllerId and resets + * uc_user_interaction to false. We check uc_user_interaction: "true" as + * a gate confirming the user actually interacted, then parse uc_settings + * to extract per-service decisions. uc_user_interaction alone is not + * enough — without uc_settings we cannot tell accept from deny and must + * not fabricate a direction. + * + * Both scenarios fail closed: any schema mismatch or partial corruption + * returns null rather than proceeding with a subset of valid entries. + * Over-consenting from malformed storage is the worst-direction bias in + * this domain. * * Must be called BEFORE UC.init() since init overwrites these keys. */ -export function detectPriorConsent(): boolean { +export function detectPriorConsent(): PriorConsentDecision { try { - // Scenario 1: GTM wrote compressed format (slow navigation / same-app refresh) const ucData = localStorage?.getItem('ucData') if (ucData) { const data = JSON.parse(ucData) const services = data?.consent?.services if (services && typeof services === 'object') { - const serviceValues = Object.values(services) - if ( - serviceValues.length > 0 && - serviceValues.every( - (s) => - typeof s === 'object' && s !== null && (s as { consent: boolean }).consent === true - ) - ) { - return true + const rawEntries = Object.entries(services) as Array<[string, unknown]> + if (rawEntries.length > 0) { + if (!rawEntries.every(isValidUcDataServiceEntry)) { + // Partial corruption: don't cherry-pick the valid subset. Fall + // through to scenario 2 — uc_settings may still be intact. + } else { + const entries = rawEntries as UcDataServiceEntry[] + if (entries.every(([, s]) => s.consent === true)) { + return { kind: 'uniform-accept' } + } + return { + kind: 'decisions', + decisions: entries.map(([serviceId, s]) => ({ + serviceId, + status: s.consent, + })), + } + } } } } - // Scenario 2: SDK wrote uc_user_interaction: "true" (fast cross-app navigation) + // uc_user_interaction gates trust in uc_settings — the SDK sets it on any + // user interaction, which confirms uc_settings holds real decisions rather + // than ruleset defaults. if (localStorage?.getItem('uc_user_interaction') === 'true') { - return true + const ucSettings = localStorage?.getItem('uc_settings') + if (ucSettings) { + const parsed = JSON.parse(ucSettings) + const services = parsed?.services + if ( + Array.isArray(services) && + services.length > 0 && + services.every(isValidUcSettingsService) + ) { + const decisions: UserDecision[] = services.map((s) => ({ + serviceId: s.id, + status: s.status, + })) + if (decisions.every((d) => d.status === true)) { + return { kind: 'uniform-accept' } + } + return { kind: 'decisions', decisions } + } + } + // Flag says interacted but uc_settings is missing or malformed. + // Don't fabricate direction — show the banner on the next init. } - return false + return null } catch { - return false + return null } } export const consentState = proxy({ - // Usercentrics state UC: null as Usercentrics | null, categories: null as BaseCategory[] | null, - // Our state showConsentToast: false, hasConsented: false, acceptAll: () => { @@ -108,6 +180,95 @@ export const consentState = proxy({ }, }) +/** + * Apply a prior consent decision (or lack of one) to the freshly-initialized + * Usercentrics SDK and the module's consentState proxy. Extracted from + * initUserCentrics to make the orchestration unit-testable without mocking + * the dynamic SDK import. Must be called after UC.init(). Mutates + * consentState synchronously and may call UC methods asynchronously. + */ +export function applyPriorDecisionToSDK( + UC: Usercentrics, + initialUIValues: { initialLayer: number }, + priorDecision: PriorConsentDecision +): void { + consentState.UC = UC + const hasConsented = UC.areAllConsentsAccepted() + + // If the SDK wants to show the banner but the user previously made a + // decision (detected via ucData or uc_settings before init overwrote + // them), silently re-apply that decision instead of re-prompting + // (FE-2648, GROWTH-790). + if (initialUIValues.initialLayer === 0 && !hasConsented && priorDecision) { + consentState.categories = UC.getCategoriesBaseInfo() + consentState.showConsentToast = false + localStorage?.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) + + if (priorDecision.kind === 'uniform-accept') { + // Uniform accept covers any currently-active service by definition, + // including any added to the ruleset since the user's decision was + // stored — acceptAllServices applies to all current services. + consentState.hasConsented = true + UC.acceptAllServices() + .then(() => { + consentState.categories = UC.getCategoriesBaseInfo() + }) + .catch(() => { + consentState.hasConsented = false + consentState.showConsentToast = true + }) + return + } + + // priorDecision.kind === 'decisions'. Only suppress the banner if the + // stored decisions cover every non-essential service the SDK currently + // knows about. If the ruleset has grown since the user's ucData/ + // uc_settings was written, force a re-prompt rather than silently + // defaulting the new service. Essentials are skipped because the SDK + // forces them on regardless of user decision. + const currentNonEssentialIds = UC.getServicesBaseInfo() + .filter((s) => !s.isEssential) + .map((s) => s.id) + const coveredIds = new Set(priorDecision.decisions.map((d) => d.serviceId)) + const allCovered = currentNonEssentialIds.every((id) => coveredIds.has(id)) + + if (!allCovered) { + // Fall through to the banner path below. Reset the early writes + // so the default-branch state assignments take effect correctly. + consentState.showConsentToast = initialUIValues.initialLayer === 0 + consentState.hasConsented = hasConsented + return + } + + // Restore the user's exact per-service state — handles deny and any + // partial/category-level decision made via Privacy Settings. hasConsented + // is computed from SDK state after the restore resolves (will be false + // unless every service was accepted). + UC.updateServices(priorDecision.decisions) + .then(() => { + consentState.hasConsented = UC.areAllConsentsAccepted() + consentState.categories = UC.getCategoriesBaseInfo() + }) + .catch(() => { + // Falling back to the banner is safer than silently flipping to a + // uniform state the user didn't choose. + consentState.showConsentToast = true + }) + return + } + + // 0 = first layer, aka show consent toast + consentState.showConsentToast = initialUIValues.initialLayer === 0 + consentState.hasConsented = hasConsented + consentState.categories = UC.getCategoriesBaseInfo() + + // If the user has previously consented (before usercentrics), accept all services + if (!hasConsented && localStorage?.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) === 'true') { + consentState.acceptAll() + localStorage.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) + } +} + async function initUserCentrics() { if (process.env.NODE_ENV === 'test' || !IS_PLATFORM) return @@ -124,7 +285,7 @@ async function initUserCentrics() { // Check for prior consent BEFORE UC.init(), which can't read the compressed // ucData format written by the GTM/Usercentrics integration (FE-2648). - const previouslyAccepted = detectPriorConsent() + const priorDecision = detectPriorConsent() try { const { default: Usercentrics } = await import('@usercentrics/cmp-browser-sdk') @@ -135,44 +296,13 @@ async function initUserCentrics() { }) const initialUIValues = await UC.init() - - consentState.UC = UC - const hasConsented = UC.areAllConsentsAccepted() - - // If the SDK wants to show the banner but the user previously accepted - // (detected via ucData or uc_user_interaction before init overwrote them), - // silently re-accept instead of showing the banner again (FE-2648). - if (initialUIValues.initialLayer === 0 && !hasConsented && previouslyAccepted) { - consentState.hasConsented = true - consentState.showConsentToast = false - consentState.categories = UC.getCategoriesBaseInfo() - localStorage?.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - UC.acceptAllServices() - .then(() => { - consentState.categories = UC.getCategoriesBaseInfo() - }) - .catch(() => { - // If re-accept fails, fall back to showing the banner - consentState.hasConsented = false - consentState.showConsentToast = true - }) - return - } - - // 0 = first layer, aka show consent toast - consentState.showConsentToast = initialUIValues.initialLayer === 0 - consentState.hasConsented = hasConsented - consentState.categories = UC.getCategoriesBaseInfo() - - // If the user has previously consented (before usercentrics), accept all services - if (!hasConsented && localStorage?.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) === 'true') { - consentState.acceptAll() - localStorage.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - } + applyPriorDecisionToSDK(UC, initialUIValues, priorDecision) } catch (error) { console.error('Failed to initialize Usercentrics:', error) - // If SDK fails but user previously accepted, honor that - if (previouslyAccepted) { + // If SDK fails but user previously accepted uniformly, honor that. + // For explicit per-service decisions we can't restore without the SDK, + // and showing the banner when the SDK is broken would fail anyway. + if (priorDecision?.kind === 'uniform-accept') { consentState.hasConsented = true } } @@ -183,8 +313,6 @@ if (typeof window !== 'undefined') { initUserCentrics() } -// Public API for consent - export function hasConsented() { return snapshot(consentState).hasConsented }