From 81b612cf802483c9ae20f55bdc9c655acd2e10cd Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser <14542336+slax57@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:55:55 +0200 Subject: [PATCH 01/13] Persist user preferences (theme and locale) in database --- registry.json | 4 +++ src/components/admin/locales-menu-button.tsx | 5 ++- src/components/admin/theme-mode-toggle.tsx | 16 +++++++--- src/components/atomic-crm/layout/Layout.tsx | 2 ++ .../atomic-crm/layout/MobileLayout.tsx | 2 ++ .../providers/fakerest/dataProvider.ts | 28 +++++++++++++++++ .../providers/supabase/dataProvider.ts | 31 +++++++++++++++++++ src/components/atomic-crm/root/CRM.tsx | 14 +++++++++ .../atomic-crm/root/usePreferencesLoader.ts | 25 +++++++++++++++ .../atomic-crm/settings/ProfilePage.tsx | 8 ++++- .../settings/SettingsPageMobile.tsx | 19 +++++++++--- .../20260401133342_add_sales_preferences.sql | 10 ++++++ supabase/schemas/01_tables.sql | 3 +- supabase/schemas/05_policies.sql | 1 + 14 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 src/components/atomic-crm/root/usePreferencesLoader.ts create mode 100644 supabase/migrations/20260401133342_add_sales_preferences.sql diff --git a/registry.json b/registry.json index e4a89fa2d8..aa8fcd8ff4 100644 --- a/registry.json +++ b/registry.json @@ -181,6 +181,10 @@ "path": "src/components/atomic-crm/sales/SalesCreate.tsx", "type": "registry:component" }, + { + "path": "src/components/atomic-crm/root/usePreferencesLoader.ts", + "type": "registry:component" + }, { "path": "src/components/atomic-crm/root/useConfigurationLoader.ts", "type": "registry:component" diff --git a/src/components/admin/locales-menu-button.tsx b/src/components/admin/locales-menu-button.tsx index d4349e65d7..ba1ccb16be 100644 --- a/src/components/admin/locales-menu-button.tsx +++ b/src/components/admin/locales-menu-button.tsx @@ -7,7 +7,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { useLocales, useLocaleState } from "ra-core"; +import type { CrmDataProvider } from "@/components/atomic-crm/providers/types"; +import { useDataProvider, useLocales, useLocaleState } from "ra-core"; /** * Language switcher button that displays a menu allowing users to select the interface language. @@ -22,6 +23,7 @@ import { useLocales, useLocaleState } from "ra-core"; export function LocalesMenuButton() { const languages = useLocales(); const [locale, setLocale] = useLocaleState(); + const dataProvider = useDataProvider(); const getNameForLocale = (locale: string): string => { const language = languages.find((language) => language.locale === locale); @@ -30,6 +32,7 @@ export function LocalesMenuButton() { const changeLocale = (locale: string) => (): void => { setLocale(locale); + dataProvider.updatePreferences({ locale }); }; if (languages.length <= 1) { diff --git a/src/components/admin/theme-mode-toggle.tsx b/src/components/admin/theme-mode-toggle.tsx index d24e207b01..415bdfa542 100644 --- a/src/components/admin/theme-mode-toggle.tsx +++ b/src/components/admin/theme-mode-toggle.tsx @@ -1,5 +1,5 @@ import { Check, Moon, Sun } from "lucide-react"; -import { useTranslate } from "ra-core"; +import { useDataProvider, useTranslate } from "ra-core"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -8,7 +8,9 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; +import type { Theme } from "@/components/admin/theme-context"; import { useTheme } from "@/components/admin/use-theme"; +import type { CrmDataProvider } from "@/components/atomic-crm/providers/types"; /** * Toggle button that lets users switch between light, dark, and system UI themes. @@ -21,6 +23,12 @@ import { useTheme } from "@/components/admin/use-theme"; export function ThemeModeToggle() { const { theme, setTheme } = useTheme(); const translate = useTranslate(); + const dataProvider = useDataProvider(); + + const handleSetTheme = (value: Theme) => { + setTheme(value); + dataProvider.updatePreferences({ theme: value }); + }; return ( @@ -32,15 +40,15 @@ export function ThemeModeToggle() { - setTheme("light")}> + handleSetTheme("light")}> {translate("crm.theme.light", { _: "Light" })} - setTheme("dark")}> + handleSetTheme("dark")}> {translate("crm.theme.dark", { _: "Dark" })} - setTheme("system")}> + handleSetTheme("system")}> {translate("crm.theme.system", { _: "System" })} diff --git a/src/components/atomic-crm/layout/Layout.tsx b/src/components/atomic-crm/layout/Layout.tsx index 4198165828..03c1326262 100644 --- a/src/components/atomic-crm/layout/Layout.tsx +++ b/src/components/atomic-crm/layout/Layout.tsx @@ -5,10 +5,12 @@ import { Error } from "@/components/admin/error"; import { Skeleton } from "@/components/ui/skeleton"; import { useConfigurationLoader } from "../root/useConfigurationLoader"; +import { usePreferencesLoader } from "../root/usePreferencesLoader"; import Header from "./Header"; export const Layout = ({ children }: { children: ReactNode }) => { useConfigurationLoader(); + usePreferencesLoader(); return ( <>
diff --git a/src/components/atomic-crm/layout/MobileLayout.tsx b/src/components/atomic-crm/layout/MobileLayout.tsx index 334ae0e820..8e60a77746 100644 --- a/src/components/atomic-crm/layout/MobileLayout.tsx +++ b/src/components/atomic-crm/layout/MobileLayout.tsx @@ -5,10 +5,12 @@ import { Suspense, type ReactNode } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { useConfigurationLoader } from "../root/useConfigurationLoader"; +import { usePreferencesLoader } from "../root/usePreferencesLoader"; import { MobileNavigation } from "./MobileNavigation"; export const MobileLayout = ({ children }: { children: ReactNode }) => { useConfigurationLoader(); + usePreferencesLoader(); return ( <> diff --git a/src/components/atomic-crm/providers/fakerest/dataProvider.ts b/src/components/atomic-crm/providers/fakerest/dataProvider.ts index 4469fd4747..3af1b74cb7 100644 --- a/src/components/atomic-crm/providers/fakerest/dataProvider.ts +++ b/src/components/atomic-crm/providers/fakerest/dataProvider.ts @@ -316,6 +316,34 @@ export const createDataProvider = ({ }); return config; }, + getPreferences: async (): Promise> => { + const identity = await getIdentity(); + if (!identity) return {}; + const { data } = await baseDataProvider.getOne("sales", { + id: identity.id, + }); + return (data?.preferences as Record) ?? {}; + }, + updatePreferences: async ( + prefs: Record, + ): Promise> => { + const identity = await getIdentity(); + if (!identity) return prefs; + const { data: sale } = await baseDataProvider.getOne("sales", { + id: identity.id, + }); + if (!sale) return prefs; + const merged = { + ...((sale?.preferences as Record) ?? {}), + ...prefs, + }; + await baseDataProvider.update("sales", { + id: identity.id, + data: { ...sale, preferences: merged }, + previousData: sale, + }); + return merged; + }, }; const dataProvider = withLifecycleCallbacks( diff --git a/src/components/atomic-crm/providers/supabase/dataProvider.ts b/src/components/atomic-crm/providers/supabase/dataProvider.ts index 26b4e749d1..f2f7e698ba 100644 --- a/src/components/atomic-crm/providers/supabase/dataProvider.ts +++ b/src/components/atomic-crm/providers/supabase/dataProvider.ts @@ -241,6 +241,37 @@ const getDataProviderWithCustomMethods = () => { }); return data.config as ConfigurationContextValue; }, + async getPreferences(): Promise> { + const { data: session } = await getSupabaseClient().auth.getSession(); + if (!session?.session?.user) return {}; + const { data } = await getSupabaseClient() + .from("sales") + .select("preferences") + .match({ user_id: session.session.user.id }) + .single(); + return (data?.preferences as Record) ?? {}; + }, + async updatePreferences( + prefs: Record, + ): Promise> { + const { data: session } = await getSupabaseClient().auth.getSession(); + if (!session?.session?.user) return prefs; + const { data: sale } = await getSupabaseClient() + .from("sales") + .select("preferences") + .match({ user_id: session.session.user.id }) + .single(); + if (!sale) return prefs; + const merged = { + ...((sale?.preferences as Record) ?? {}), + ...prefs, + }; + await getSupabaseClient() + .from("sales") + .update({ preferences: merged }) + .match({ user_id: session.session.user.id }); + return merged; + }, } satisfies DataProvider; }; diff --git a/src/components/atomic-crm/root/CRM.tsx b/src/components/atomic-crm/root/CRM.tsx index e3101afa8f..77e956afb1 100644 --- a/src/components/atomic-crm/root/CRM.tsx +++ b/src/components/atomic-crm/root/CRM.tsx @@ -185,6 +185,13 @@ export const CRM = ({ } catch { // Non-critical: config will load via useConfigurationLoader } + try { + const prefs = await dataProvider.getPreferences(); + if (prefs.theme) store.setItem("theme", prefs.theme); + if (prefs.locale) store.setItem("locale", prefs.locale); + } catch { + // Non-critical: prefs will load via usePreferencesLoader + } return result; }, handleCallback: async (params: any) => { @@ -202,6 +209,13 @@ export const CRM = ({ } catch { // Non-critical: config will load via useConfigurationLoader } + try { + const prefs = await dataProvider.getPreferences(); + if (prefs.theme) store.setItem("theme", prefs.theme); + if (prefs.locale) store.setItem("locale", prefs.locale); + } catch { + // Non-critical: prefs will load via usePreferencesLoader + } return result; }, logout: async (params: any) => { diff --git a/src/components/atomic-crm/root/usePreferencesLoader.ts b/src/components/atomic-crm/root/usePreferencesLoader.ts new file mode 100644 index 0000000000..53b23d643e --- /dev/null +++ b/src/components/atomic-crm/root/usePreferencesLoader.ts @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { useDataProvider, useStore } from "ra-core"; + +import type { CrmDataProvider } from "../providers/types"; + +export const usePreferencesLoader = () => { + const dataProvider = useDataProvider(); + const [, setTheme] = useStore("theme"); + const [, setLocale] = useStore("locale"); + + const { data } = useQuery>({ + queryKey: ["preferences"], + queryFn: () => dataProvider.getPreferences(), + staleTime: 1000 * 60 * 5, // 5 minutes + retry: false, + }); + + useEffect(() => { + if (data) { + if (data.theme) setTheme(data.theme); + if (data.locale) setLocale(data.locale); + } + }, [data, setTheme, setLocale]); +}; diff --git a/src/components/atomic-crm/settings/ProfilePage.tsx b/src/components/atomic-crm/settings/ProfilePage.tsx index 4aae578d5f..1491e1ea76 100644 --- a/src/components/atomic-crm/settings/ProfilePage.tsx +++ b/src/components/atomic-crm/settings/ProfilePage.tsx @@ -275,6 +275,12 @@ const LanguageSelector = () => { const translate = useTranslate(); const locales = useLocales(); const [locale, setLocale] = useLocaleState(); + const dataProvider = useDataProvider(); + + const handleSetLocale = (value: string) => { + setLocale(value); + dataProvider.updatePreferences({ locale: value }); + }; if (locales.length <= 1) { return null; @@ -285,7 +291,7 @@ const LanguageSelector = () => {

{translate("crm.language")}

- diff --git a/src/components/atomic-crm/settings/SettingsPageMobile.tsx b/src/components/atomic-crm/settings/SettingsPageMobile.tsx index abbef18e62..df5bc0884f 100644 --- a/src/components/atomic-crm/settings/SettingsPageMobile.tsx +++ b/src/components/atomic-crm/settings/SettingsPageMobile.tsx @@ -1,4 +1,5 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Theme } from "@/components/admin/theme-context"; import { useTheme } from "@/components/admin/use-theme"; import { KeyRound } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -371,6 +372,12 @@ const LanguageRow = () => { const translate = useTranslate(); const locales = useLocales(); const [locale, setLocale] = useLocaleState(); + const dataProvider = useDataProvider(); + + const handleSetLocale = (value: string) => { + setLocale(value); + dataProvider.updatePreferences({ locale: value }); + }; if (locales.length <= 1) return null; @@ -382,7 +389,7 @@ const LanguageRow = () => { - { const ThemeRow = () => { const translate = useTranslate(); const { theme, setTheme } = useTheme(); + const dataProvider = useDataProvider(); + + const handleSetTheme = (value: Theme) => { + setTheme(value); + dataProvider.updatePreferences({ theme: value }); + }; return ( @@ -414,9 +427,7 @@ const ThemeRow = () => { - value && setTheme(value as "light" | "dark" | "system") - } + onValueChange={(value) => value && handleSetTheme(value as Theme)} size="lg" variant="outline" className="w-full" diff --git a/supabase/migrations/20260401133342_add_sales_preferences.sql b/supabase/migrations/20260401133342_add_sales_preferences.sql new file mode 100644 index 0000000000..e56d6856c6 --- /dev/null +++ b/supabase/migrations/20260401133342_add_sales_preferences.sql @@ -0,0 +1,10 @@ +alter table "public"."sales" add column "preferences" jsonb; + +create policy "Enable self-update for authenticated users" + on "public"."sales" + as permissive + for update + to authenticated +using ((user_id = auth.uid())) +with check ((user_id = auth.uid())); + diff --git a/supabase/schemas/01_tables.sql b/supabase/schemas/01_tables.sql index 2440885529..3a30bc242e 100644 --- a/supabase/schemas/01_tables.sql +++ b/supabase/schemas/01_tables.sql @@ -96,7 +96,8 @@ create table public.sales ( administrator boolean not null, user_id uuid not null, avatar jsonb, - disabled boolean not null default false + disabled boolean not null default false, + preferences jsonb ); create unique index uq__sales__user_id on public.sales using btree (user_id); diff --git a/supabase/schemas/05_policies.sql b/supabase/schemas/05_policies.sql index 9be5ab2c41..68cb0649e4 100644 --- a/supabase/schemas/05_policies.sql +++ b/supabase/schemas/05_policies.sql @@ -47,6 +47,7 @@ create policy "Deal Notes Delete Policy" on public.deal_notes for delete to auth -- Sales create policy "Enable read access for authenticated users" on public.sales for select to authenticated using (true); +create policy "Enable self-update for authenticated users" on public.sales for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid()); -- Tags create policy "Enable read access for authenticated users" on public.tags for select to authenticated using (true); From 38b2fd2bbd116ba7b822e53b87959611019fb36d Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser <14542336+slax57@users.noreply.github.com> Date: Wed, 1 Apr 2026 17:03:50 +0200 Subject: [PATCH 02/13] factorize pre-fetching the configuration and preferences --- src/components/atomic-crm/root/CRM.tsx | 54 +++++++++++--------------- 1 file changed, 22 insertions(+), 32 deletions(-) diff --git a/src/components/atomic-crm/root/CRM.tsx b/src/components/atomic-crm/root/CRM.tsx index 77e956afb1..db85535829 100644 --- a/src/components/atomic-crm/root/CRM.tsx +++ b/src/components/atomic-crm/root/CRM.tsx @@ -170,28 +170,32 @@ export const CRM = ({ const isMobile = useIsMobile(); - // on login, pre-fetch the configuration to avoid a flickering - // when accessing the app for the first time + // on login, pre-fetch the configuration and preferences to avoid + // a flickering when accessing the app for the first time + const prefetchConfigAndPreferences = async () => { + try { + const config = await dataProvider.getConfiguration(); + if (Object.keys(config).length > 0) { + store.setItem(CONFIGURATION_STORE_KEY, config); + } + } catch { + // Non-critical: config will load via useConfigurationLoader + } + try { + const prefs = await dataProvider.getPreferences(); + if (prefs.theme) store.setItem("theme", prefs.theme); + if (prefs.locale) store.setItem("locale", prefs.locale); + } catch { + // Non-critical: prefs will load via usePreferencesLoader + } + }; + const wrappedAuthProvider = useMemo( () => ({ ...authProvider, login: async (params: any) => { const result = await authProvider.login(params); - try { - const config = await dataProvider.getConfiguration(); - if (Object.keys(config).length > 0) { - store.setItem(CONFIGURATION_STORE_KEY, config); - } - } catch { - // Non-critical: config will load via useConfigurationLoader - } - try { - const prefs = await dataProvider.getPreferences(); - if (prefs.theme) store.setItem("theme", prefs.theme); - if (prefs.locale) store.setItem("locale", prefs.locale); - } catch { - // Non-critical: prefs will load via usePreferencesLoader - } + await prefetchConfigAndPreferences(); return result; }, handleCallback: async (params: any) => { @@ -201,21 +205,7 @@ export const CRM = ({ ); } const result = await authProvider.handleCallback(params); - try { - const config = await dataProvider.getConfiguration(); - if (Object.keys(config).length > 0) { - store.setItem(CONFIGURATION_STORE_KEY, config); - } - } catch { - // Non-critical: config will load via useConfigurationLoader - } - try { - const prefs = await dataProvider.getPreferences(); - if (prefs.theme) store.setItem("theme", prefs.theme); - if (prefs.locale) store.setItem("locale", prefs.locale); - } catch { - // Non-critical: prefs will load via usePreferencesLoader - } + await prefetchConfigAndPreferences(); return result; }, logout: async (params: any) => { From 4db9858e525e4397c382a676a9ade399dbedfc8c Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser <14542336+slax57@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:43:35 +0200 Subject: [PATCH 03/13] add error notifications --- src/components/admin/locales-menu-button.tsx | 19 +++++++++++++-- src/components/admin/theme-mode-toggle.tsx | 14 +++++++++-- .../atomic-crm/settings/ProfilePage.tsx | 12 +++++++++- .../settings/SettingsPageMobile.tsx | 24 +++++++++++++++++-- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/components/admin/locales-menu-button.tsx b/src/components/admin/locales-menu-button.tsx index ba1ccb16be..48d9ee73aa 100644 --- a/src/components/admin/locales-menu-button.tsx +++ b/src/components/admin/locales-menu-button.tsx @@ -8,7 +8,12 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import type { CrmDataProvider } from "@/components/atomic-crm/providers/types"; -import { useDataProvider, useLocales, useLocaleState } from "ra-core"; +import { + useDataProvider, + useLocales, + useLocaleState, + useNotify, +} from "ra-core"; /** * Language switcher button that displays a menu allowing users to select the interface language. @@ -24,6 +29,7 @@ export function LocalesMenuButton() { const languages = useLocales(); const [locale, setLocale] = useLocaleState(); const dataProvider = useDataProvider(); + const notify = useNotify(); const getNameForLocale = (locale: string): string => { const language = languages.find((language) => language.locale === locale); @@ -32,7 +38,16 @@ export function LocalesMenuButton() { const changeLocale = (locale: string) => (): void => { setLocale(locale); - dataProvider.updatePreferences({ locale }); + dataProvider + .updatePreferences({ locale }) + .catch((e) => + notify( + typeof e?.message === "string" + ? e?.message + : "ra.notification.http_error", + { type: "error" }, + ), + ); }; if (languages.length <= 1) { diff --git a/src/components/admin/theme-mode-toggle.tsx b/src/components/admin/theme-mode-toggle.tsx index 415bdfa542..bb328dd1cd 100644 --- a/src/components/admin/theme-mode-toggle.tsx +++ b/src/components/admin/theme-mode-toggle.tsx @@ -1,5 +1,5 @@ import { Check, Moon, Sun } from "lucide-react"; -import { useDataProvider, useTranslate } from "ra-core"; +import { useDataProvider, useNotify, useTranslate } from "ra-core"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -24,10 +24,20 @@ export function ThemeModeToggle() { const { theme, setTheme } = useTheme(); const translate = useTranslate(); const dataProvider = useDataProvider(); + const notify = useNotify(); const handleSetTheme = (value: Theme) => { setTheme(value); - dataProvider.updatePreferences({ theme: value }); + dataProvider + .updatePreferences({ theme: value }) + .catch((e) => + notify( + typeof e?.message === "string" + ? e?.message + : "ra.notification.http_error", + { type: "error" }, + ), + ); }; return ( diff --git a/src/components/atomic-crm/settings/ProfilePage.tsx b/src/components/atomic-crm/settings/ProfilePage.tsx index 1491e1ea76..62242524dc 100644 --- a/src/components/atomic-crm/settings/ProfilePage.tsx +++ b/src/components/atomic-crm/settings/ProfilePage.tsx @@ -276,10 +276,20 @@ const LanguageSelector = () => { const locales = useLocales(); const [locale, setLocale] = useLocaleState(); const dataProvider = useDataProvider(); + const notify = useNotify(); const handleSetLocale = (value: string) => { setLocale(value); - dataProvider.updatePreferences({ locale: value }); + dataProvider + .updatePreferences({ locale: value }) + .catch((e) => + notify( + typeof e?.message === "string" + ? e?.message + : "ra.notification.http_error", + { type: "error" }, + ), + ); }; if (locales.length <= 1) { diff --git a/src/components/atomic-crm/settings/SettingsPageMobile.tsx b/src/components/atomic-crm/settings/SettingsPageMobile.tsx index df5bc0884f..bfd080147d 100644 --- a/src/components/atomic-crm/settings/SettingsPageMobile.tsx +++ b/src/components/atomic-crm/settings/SettingsPageMobile.tsx @@ -373,10 +373,20 @@ const LanguageRow = () => { const locales = useLocales(); const [locale, setLocale] = useLocaleState(); const dataProvider = useDataProvider(); + const notify = useNotify(); const handleSetLocale = (value: string) => { setLocale(value); - dataProvider.updatePreferences({ locale: value }); + dataProvider + .updatePreferences({ locale: value }) + .catch((e) => + notify( + typeof e?.message === "string" + ? e?.message + : "ra.notification.http_error", + { type: "error" }, + ), + ); }; if (locales.length <= 1) return null; @@ -413,10 +423,20 @@ const ThemeRow = () => { const translate = useTranslate(); const { theme, setTheme } = useTheme(); const dataProvider = useDataProvider(); + const notify = useNotify(); const handleSetTheme = (value: Theme) => { setTheme(value); - dataProvider.updatePreferences({ theme: value }); + dataProvider + .updatePreferences({ theme: value }) + .catch((e) => + notify( + typeof e?.message === "string" + ? e?.message + : "ra.notification.http_error", + { type: "error" }, + ), + ); }; return ( From 83bbcfd4d4f4474ad2703d1ca85d7269230f4091 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 3 Aug 2026 10:09:07 +0000 Subject: [PATCH 04/13] Persist preferences through an explicit helper, restrict the self-update grant Reworks the review feedback on #246. Centralise the write mechanism, not the trigger. usePersistPreference holds the single copy of the request, the query cache reconciliation and the error notification; each control still calls it explicitly right after setTheme or setLocale. Observing the store instead would have to infer intent from a derived value, which is wrong whenever the value moves for another reason: the i18n remount, the cross-tab mirror, or the load itself. Fixes the language round trip. ra-core's I18nContextProvider remounts its whole subtree on every locale change, so anything holding per-mount state loses it while the query cache survives. The cache is now written optimistically at call time and rolled back on failure, so a remount re-applies the fresh value instead of an unreconciled one. Covered by a regression test. Security: the self-update policy on `sales` allowed updating every column of one's own row, and `06_grants.sql` grants all to `authenticated`, so a user could set `administrator = true` on themselves. Restrict the write to the `preferences` column. Admin edits go through the `users` edge function with the service_role and are unaffected. Also: - re-wire theme-mode-toggle, which the merge with main silently dropped - namespace the query key per user, so a shared device cannot leak preferences through the mobile persisted cache - keep the server-side merge and send only the changed key, so a stale snapshot cannot wipe another device's value - detect zero-row updates, which PostgREST reports as success - tolerate a missing sales row on read - add `preferences` to the `Sale` type - fix the missing `useMemo` dependency in CRM.tsx - regenerate the migration, its timestamp predated already applied ones --- registry.json | 12 ++ src/components/admin/locales-menu-button.tsx | 23 +-- src/components/admin/theme-mode-toggle.tsx | 14 +- .../providers/commons/englishCrmMessages.ts | 3 + .../providers/commons/frenchCrmMessages.ts | 3 + .../providers/fakerest/dataProvider.ts | 26 ++-- .../providers/supabase/dataProvider.ts | 51 ++++--- src/components/atomic-crm/root/CRM.tsx | 16 +- src/components/atomic-crm/root/preferences.ts | 3 + .../root/usePersistPreference.test.tsx | 137 ++++++++++++++++++ .../atomic-crm/root/usePersistPreference.ts | 34 +++++ .../atomic-crm/root/usePreferencesLoader.ts | 25 ++-- .../atomic-crm/settings/ProfilePage.tsx | 15 +- .../settings/SettingsPageMobile.tsx | 29 +--- src/components/atomic-crm/types.ts | 7 + src/test/StoryWrapper.tsx | 6 +- ... 20260803084609_add_sales_preferences.sql} | 2 + supabase/schemas/06_grants.sql | 3 + 18 files changed, 301 insertions(+), 108 deletions(-) create mode 100644 src/components/atomic-crm/root/preferences.ts create mode 100644 src/components/atomic-crm/root/usePersistPreference.test.tsx create mode 100644 src/components/atomic-crm/root/usePersistPreference.ts rename supabase/migrations/{20260401133342_add_sales_preferences.sql => 20260803084609_add_sales_preferences.sql} (65%) diff --git a/registry.json b/registry.json index cbd0a5982e..4cdc27ec40 100644 --- a/registry.json +++ b/registry.json @@ -185,10 +185,18 @@ "path": "src/components/atomic-crm/root/usePreferencesLoader.ts", "type": "registry:component" }, + { + "path": "src/components/atomic-crm/root/usePersistPreference.ts", + "type": "registry:component" + }, { "path": "src/components/atomic-crm/root/useConfigurationLoader.ts", "type": "registry:component" }, + { + "path": "src/components/atomic-crm/root/preferences.ts", + "type": "registry:component" + }, { "path": "src/components/atomic-crm/root/defaultConfiguration.ts", "type": "registry:component" @@ -485,6 +493,10 @@ "path": "src/components/atomic-crm/misc/ActiveFilterButton.tsx", "type": "registry:component" }, + { + "path": "src/components/atomic-crm/login/authConfig.ts", + "type": "registry:component" + }, { "path": "src/components/atomic-crm/login/StartPage.tsx", "type": "registry:component" diff --git a/src/components/admin/locales-menu-button.tsx b/src/components/admin/locales-menu-button.tsx index 48d9ee73aa..350ea0d4be 100644 --- a/src/components/admin/locales-menu-button.tsx +++ b/src/components/admin/locales-menu-button.tsx @@ -7,13 +7,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import type { CrmDataProvider } from "@/components/atomic-crm/providers/types"; -import { - useDataProvider, - useLocales, - useLocaleState, - useNotify, -} from "ra-core"; +import { usePersistPreference } from "@/components/atomic-crm/root/usePersistPreference"; +import { useLocales, useLocaleState } from "ra-core"; /** * Language switcher button that displays a menu allowing users to select the interface language. @@ -28,8 +23,7 @@ import { export function LocalesMenuButton() { const languages = useLocales(); const [locale, setLocale] = useLocaleState(); - const dataProvider = useDataProvider(); - const notify = useNotify(); + const persist = usePersistPreference(); const getNameForLocale = (locale: string): string => { const language = languages.find((language) => language.locale === locale); @@ -38,16 +32,7 @@ export function LocalesMenuButton() { const changeLocale = (locale: string) => (): void => { setLocale(locale); - dataProvider - .updatePreferences({ locale }) - .catch((e) => - notify( - typeof e?.message === "string" - ? e?.message - : "ra.notification.http_error", - { type: "error" }, - ), - ); + persist({ locale }); }; if (languages.length <= 1) { diff --git a/src/components/admin/theme-mode-toggle.tsx b/src/components/admin/theme-mode-toggle.tsx index 4dfbdb227e..e2987ebe74 100644 --- a/src/components/admin/theme-mode-toggle.tsx +++ b/src/components/admin/theme-mode-toggle.tsx @@ -7,7 +7,9 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; +import type { Theme } from "@/components/admin/theme-context"; import { useTheme } from "@/components/admin/use-theme"; +import { usePersistPreference } from "@/components/atomic-crm/root/usePersistPreference"; /** * Toggle button that lets users switch between light, dark, and system UI themes. @@ -19,6 +21,12 @@ import { useTheme } from "@/components/admin/use-theme"; */ export function ThemeModeToggle() { const { theme, setTheme } = useTheme(); + const persist = usePersistPreference(); + + const handleSetTheme = (value: Theme) => { + setTheme(value); + persist({ theme: value }); + }; return ( @@ -30,15 +38,15 @@ export function ThemeModeToggle() { - setTheme("light")}> + handleSetTheme("light")}> Light - setTheme("dark")}> + handleSetTheme("dark")}> Dark - setTheme("system")}> + handleSetTheme("system")}> System diff --git a/src/components/atomic-crm/providers/commons/englishCrmMessages.ts b/src/components/atomic-crm/providers/commons/englishCrmMessages.ts index de9e8443c2..b22762e21a 100644 --- a/src/components/atomic-crm/providers/commons/englishCrmMessages.ts +++ b/src/components/atomic-crm/providers/commons/englishCrmMessages.ts @@ -517,6 +517,9 @@ export const englishCrmMessages = { }, }, }, + preferences: { + update_error: "Could not save your preferences. Please try again", + }, theme: { dark: "Dark", label: "Theme", diff --git a/src/components/atomic-crm/providers/commons/frenchCrmMessages.ts b/src/components/atomic-crm/providers/commons/frenchCrmMessages.ts index 5d30e91f85..e102c0624c 100644 --- a/src/components/atomic-crm/providers/commons/frenchCrmMessages.ts +++ b/src/components/atomic-crm/providers/commons/frenchCrmMessages.ts @@ -524,6 +524,9 @@ export const frenchCrmMessages = { }, }, }, + preferences: { + update_error: "Vos préférences n'ont pas pu être enregistrées. Réessayez", + }, theme: { dark: "Sombre", label: "Thème", diff --git a/src/components/atomic-crm/providers/fakerest/dataProvider.ts b/src/components/atomic-crm/providers/fakerest/dataProvider.ts index 3af1b74cb7..46b3a7ba81 100644 --- a/src/components/atomic-crm/providers/fakerest/dataProvider.ts +++ b/src/components/atomic-crm/providers/fakerest/dataProvider.ts @@ -18,6 +18,7 @@ import type { SalesFormData, SignUpData, Task, + UserPreferences, } from "../../types"; import type { ConfigurationContextValue } from "../../root/ConfigurationContext"; import { getActivityLog } from "../commons/activity"; @@ -316,33 +317,30 @@ export const createDataProvider = ({ }); return config; }, - getPreferences: async (): Promise> => { + getPreferences: async (): Promise => { const identity = await getIdentity(); if (!identity) return {}; - const { data } = await baseDataProvider.getOne("sales", { + const { data } = await baseDataProvider.getOne("sales", { id: identity.id, }); - return (data?.preferences as Record) ?? {}; + return data?.preferences ?? {}; }, updatePreferences: async ( - prefs: Record, - ): Promise> => { + patch: Partial, + ): Promise => { const identity = await getIdentity(); - if (!identity) return prefs; - const { data: sale } = await baseDataProvider.getOne("sales", { + if (!identity) return patch; + const { data: sale } = await baseDataProvider.getOne("sales", { id: identity.id, }); - if (!sale) return prefs; - const merged = { - ...((sale?.preferences as Record) ?? {}), - ...prefs, - }; + if (!sale) return patch; + const preferences = { ...(sale.preferences ?? {}), ...patch }; await baseDataProvider.update("sales", { id: identity.id, - data: { ...sale, preferences: merged }, + data: { preferences }, previousData: sale, }); - return merged; + return preferences; }, }; diff --git a/src/components/atomic-crm/providers/supabase/dataProvider.ts b/src/components/atomic-crm/providers/supabase/dataProvider.ts index f1b9e9fbd3..5367d26c07 100644 --- a/src/components/atomic-crm/providers/supabase/dataProvider.ts +++ b/src/components/atomic-crm/providers/supabase/dataProvider.ts @@ -14,6 +14,7 @@ import type { Sale, SalesFormData, SignUpData, + UserPreferences, } from "../../types"; import type { ConfigurationContextValue } from "../../root/ConfigurationContext"; import { ATTACHMENTS_BUCKET } from "../commons/attachments"; @@ -241,36 +242,52 @@ const getDataProviderWithCustomMethods = () => { }); return data.config as ConfigurationContextValue; }, - async getPreferences(): Promise> { + async getPreferences(): Promise { const { data: session } = await getSupabaseClient().auth.getSession(); if (!session?.session?.user) return {}; - const { data } = await getSupabaseClient() + const { data, error } = await getSupabaseClient() .from("sales") .select("preferences") .match({ user_id: session.session.user.id }) - .single(); - return (data?.preferences as Record) ?? {}; + .maybeSingle(); + if (error) { + console.error("getPreferences.error", error); + throw error; + } + return (data?.preferences as UserPreferences) ?? {}; }, async updatePreferences( - prefs: Record, - ): Promise> { + patch: Partial, + ): Promise { const { data: session } = await getSupabaseClient().auth.getSession(); - if (!session?.session?.user) return prefs; - const { data: sale } = await getSupabaseClient() + if (!session?.session?.user) return patch; + const { data: sale, error: readError } = await getSupabaseClient() .from("sales") .select("preferences") .match({ user_id: session.session.user.id }) - .single(); - if (!sale) return prefs; - const merged = { - ...((sale?.preferences as Record) ?? {}), - ...prefs, + .maybeSingle(); + if (readError) { + console.error("updatePreferences.error", readError); + throw readError; + } + const preferences = { + ...((sale?.preferences as UserPreferences) ?? {}), + ...patch, }; - await getSupabaseClient() + const { data, error } = await getSupabaseClient() .from("sales") - .update({ preferences: merged }) - .match({ user_id: session.session.user.id }); - return merged; + .update({ preferences }) + .match({ user_id: session.session.user.id }) + .select("preferences") + .maybeSingle(); + if (error) { + console.error("updatePreferences.error", error); + throw error; + } + if (!data) { + throw new Error("Failed to update preferences"); + } + return data.preferences as UserPreferences; }, } satisfies DataProvider; }; diff --git a/src/components/atomic-crm/root/CRM.tsx b/src/components/atomic-crm/root/CRM.tsx index 70559e84c3..5d28552873 100644 --- a/src/components/atomic-crm/root/CRM.tsx +++ b/src/components/atomic-crm/root/CRM.tsx @@ -5,7 +5,7 @@ import type { LayoutComponent, } from "ra-core"; import { CustomRoutes, localStorageStore, Resource } from "ra-core"; -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { Route } from "react-router"; import { QueryClient } from "@tanstack/react-query"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; @@ -170,7 +170,7 @@ export const CRM = ({ // on login, pre-fetch the configuration and preferences to avoid // a flickering when accessing the app for the first time - const prefetchConfigAndPreferences = async () => { + const prefetchConfigAndPreferences = useCallback(async () => { try { const config = await dataProvider.getConfiguration(); if (Object.keys(config).length > 0) { @@ -180,13 +180,13 @@ export const CRM = ({ // Non-critical: config will load via useConfigurationLoader } try { - const prefs = await dataProvider.getPreferences(); - if (prefs.theme) store.setItem("theme", prefs.theme); - if (prefs.locale) store.setItem("locale", prefs.locale); + const preferences = await dataProvider.getPreferences(); + if (preferences.theme) store.setItem("theme", preferences.theme); + if (preferences.locale) store.setItem("locale", preferences.locale); } catch { - // Non-critical: prefs will load via usePreferencesLoader + // Non-critical: preferences will load via usePreferencesLoader } - }; + }, [dataProvider, store]); const wrappedAuthProvider = useMemo( () => ({ @@ -215,7 +215,7 @@ export const CRM = ({ return authProvider.logout(params); }, }), - [authProvider, dataProvider, store], + [authProvider, prefetchConfigAndPreferences, store], ); const ResponsiveAdmin = isMobile ? MobileAdmin : DesktopAdmin; diff --git a/src/components/atomic-crm/root/preferences.ts b/src/components/atomic-crm/root/preferences.ts new file mode 100644 index 0000000000..63c02f0f04 --- /dev/null +++ b/src/components/atomic-crm/root/preferences.ts @@ -0,0 +1,3 @@ +export const PREFERENCES_QUERY_KEY = "preferences"; + +export const PREFERENCES_STALE_TIME_MS = 5 * 60 * 1000; diff --git a/src/components/atomic-crm/root/usePersistPreference.test.tsx b/src/components/atomic-crm/root/usePersistPreference.test.tsx new file mode 100644 index 0000000000..c8c4965231 --- /dev/null +++ b/src/components/atomic-crm/root/usePersistPreference.test.tsx @@ -0,0 +1,137 @@ +import polyglotI18nProvider from "ra-i18n-polyglot"; +import { mergeTranslations, useLocaleState, useLocales } from "ra-core"; +import englishMessages from "ra-language-english"; +import { render } from "vitest-browser-react"; + +import { useTheme } from "@/components/admin/use-theme"; +import { englishCrmMessages } from "../providers/commons/englishCrmMessages"; +import type { UserPreferences } from "../types"; +import { StoryWrapper } from "@/test/StoryWrapper"; +import { usePersistPreference } from "./usePersistPreference"; +import { usePreferencesLoader } from "./usePreferencesLoader"; + +const catalog = mergeTranslations(englishMessages, englishCrmMessages); + +const twoLocalesI18nProvider = polyglotI18nProvider( + () => catalog, + "en", + [ + { locale: "en", name: "English" }, + { locale: "fr", name: "Français" }, + ], + { allowMissing: true }, +); + +const createFakeServer = (initial: UserPreferences) => { + let stored: UserPreferences = { ...initial }; + return { + read: () => ({ ...stored }), + getPreferences: async () => ({ ...stored }), + updatePreferences: async (patch: Partial) => { + stored = { ...stored, ...patch }; + return { ...stored }; + }, + }; +}; + +const Probe = () => { + usePreferencesLoader(); + const { theme, setTheme } = useTheme(); + const [locale, setLocale] = useLocaleState(); + const locales = useLocales(); + const persist = usePersistPreference(); + + return ( +
+

{`theme: ${theme}`}

+

{`locale: ${locale}`}

+

{`locales: ${locales.length}`}

+ + +
+ ); +}; + +describe("preference persistence", () => { + it("applies the preferences stored on the server", async () => { + const server = createFakeServer({ theme: "dark", locale: "en" }); + const screen = await render( + + + , + ); + + await expect.element(screen.getByText("theme: dark")).toBeVisible(); + }); + + it("keeps the new locale applied and stores it", async () => { + const server = createFakeServer({ theme: "dark", locale: "en" }); + const screen = await render( + + + , + ); + await expect.element(screen.getByText("locale: en")).toBeVisible(); + + await screen.getByRole("button", { name: "set french locale" }).click(); + + await expect.element(screen.getByText("locale: fr")).toBeVisible(); + await vi.waitFor(() => expect(server.read().locale).toBe("fr")); + }); + + it("stores a theme change without discarding the stored locale", async () => { + const server = createFakeServer({ theme: "dark", locale: "fr" }); + const screen = await render( + + + , + ); + await expect.element(screen.getByText("theme: dark")).toBeVisible(); + + await screen.getByRole("button", { name: "set light theme" }).click(); + + await expect.element(screen.getByText("theme: light")).toBeVisible(); + await vi.waitFor(() => + expect(server.read()).toEqual({ theme: "light", locale: "fr" }), + ); + }); + + it("reverts the change and warns the user when the server rejects it", async () => { + const server = createFakeServer({ theme: "dark", locale: "en" }); + const screen = await render( + Promise.reject(new Error("Denied")), + }} + > + + , + ); + await expect.element(screen.getByText("theme: dark")).toBeVisible(); + + await screen.getByRole("button", { name: "set light theme" }).click(); + + await expect + .element( + screen.getByText("Could not save your preferences", { exact: false }), + ) + .toBeVisible(); + await expect.element(screen.getByText("theme: dark")).toBeVisible(); + expect(server.read().theme).toBe("dark"); + }); +}); diff --git a/src/components/atomic-crm/root/usePersistPreference.ts b/src/components/atomic-crm/root/usePersistPreference.ts new file mode 100644 index 0000000000..5f94139879 --- /dev/null +++ b/src/components/atomic-crm/root/usePersistPreference.ts @@ -0,0 +1,34 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useDataProvider, useGetIdentity, useNotify } from "ra-core"; +import { useCallback } from "react"; + +import type { CrmDataProvider } from "../providers/types"; +import type { UserPreferences } from "../types"; +import { PREFERENCES_QUERY_KEY } from "./preferences"; + +export const usePersistPreference = () => { + const dataProvider = useDataProvider(); + const { identity } = useGetIdentity(); + const queryClient = useQueryClient(); + const notify = useNotify(); + + return useCallback( + (patch: Partial) => { + const queryKey = [PREFERENCES_QUERY_KEY, identity?.id]; + const previous = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, { ...previous, ...patch }); + + dataProvider + .updatePreferences(patch) + .then((preferences) => queryClient.setQueryData(queryKey, preferences)) + .catch(() => { + queryClient.setQueryData(queryKey, previous); + notify("crm.preferences.update_error", { + type: "error", + messageArgs: { _: "Could not save your preferences" }, + }); + }); + }, + [dataProvider, identity?.id, queryClient, notify], + ); +}; diff --git a/src/components/atomic-crm/root/usePreferencesLoader.ts b/src/components/atomic-crm/root/usePreferencesLoader.ts index 53b23d643e..738ed7ab81 100644 --- a/src/components/atomic-crm/root/usePreferencesLoader.ts +++ b/src/components/atomic-crm/root/usePreferencesLoader.ts @@ -1,25 +1,32 @@ import { useQuery } from "@tanstack/react-query"; +import { useDataProvider, useGetIdentity, useStore } from "ra-core"; import { useEffect } from "react"; -import { useDataProvider, useStore } from "ra-core"; +import type { Theme } from "@/components/admin/theme-context"; import type { CrmDataProvider } from "../providers/types"; +import type { UserPreferences } from "../types"; +import { + PREFERENCES_QUERY_KEY, + PREFERENCES_STALE_TIME_MS, +} from "./preferences"; export const usePreferencesLoader = () => { const dataProvider = useDataProvider(); - const [, setTheme] = useStore("theme"); + const { identity } = useGetIdentity(); + const [, setTheme] = useStore("theme"); const [, setLocale] = useStore("locale"); - const { data } = useQuery>({ - queryKey: ["preferences"], + const { data } = useQuery({ + queryKey: [PREFERENCES_QUERY_KEY, identity?.id], queryFn: () => dataProvider.getPreferences(), - staleTime: 1000 * 60 * 5, // 5 minutes + enabled: !!identity, + staleTime: PREFERENCES_STALE_TIME_MS, retry: false, }); useEffect(() => { - if (data) { - if (data.theme) setTheme(data.theme); - if (data.locale) setLocale(data.locale); - } + if (!data) return; + if (data.theme) setTheme(data.theme); + if (data.locale) setLocale(data.locale); }, [data, setTheme, setLocale]); }; diff --git a/src/components/atomic-crm/settings/ProfilePage.tsx b/src/components/atomic-crm/settings/ProfilePage.tsx index 62242524dc..8fbc0984dc 100644 --- a/src/components/atomic-crm/settings/ProfilePage.tsx +++ b/src/components/atomic-crm/settings/ProfilePage.tsx @@ -33,6 +33,7 @@ import { import ImageEditorField from "../misc/ImageEditorField"; import type { CrmDataProvider } from "../providers/types"; +import { usePersistPreference } from "../root/usePersistPreference"; import type { Sale, SalesFormData } from "../types"; export const ProfilePage = () => { @@ -275,21 +276,11 @@ const LanguageSelector = () => { const translate = useTranslate(); const locales = useLocales(); const [locale, setLocale] = useLocaleState(); - const dataProvider = useDataProvider(); - const notify = useNotify(); + const persist = usePersistPreference(); const handleSetLocale = (value: string) => { setLocale(value); - dataProvider - .updatePreferences({ locale: value }) - .catch((e) => - notify( - typeof e?.message === "string" - ? e?.message - : "ra.notification.http_error", - { type: "error" }, - ), - ); + persist({ locale: value }); }; if (locales.length <= 1) { diff --git a/src/components/atomic-crm/settings/SettingsPageMobile.tsx b/src/components/atomic-crm/settings/SettingsPageMobile.tsx index eed5c58538..ca1b69c3ce 100644 --- a/src/components/atomic-crm/settings/SettingsPageMobile.tsx +++ b/src/components/atomic-crm/settings/SettingsPageMobile.tsx @@ -47,6 +47,7 @@ import MobileHeader from "../layout/MobileHeader"; import { ChangelogPage } from "../misc/ChangelogPage"; import ImageEditorField from "../misc/ImageEditorField"; import type { CrmDataProvider } from "../providers/types"; +import { usePersistPreference } from "../root/usePersistPreference"; import type { SalesFormData } from "../types"; const ChangePasswordButton = () => { @@ -375,21 +376,11 @@ const LanguageRow = () => { const translate = useTranslate(); const locales = useLocales(); const [locale, setLocale] = useLocaleState(); - const dataProvider = useDataProvider(); - const notify = useNotify(); + const persist = usePersistPreference(); const handleSetLocale = (value: string) => { setLocale(value); - dataProvider - .updatePreferences({ locale: value }) - .catch((e) => - notify( - typeof e?.message === "string" - ? e?.message - : "ra.notification.http_error", - { type: "error" }, - ), - ); + persist({ locale: value }); }; if (locales.length <= 1) return null; @@ -425,21 +416,11 @@ const LanguageRow = () => { const ThemeRow = () => { const translate = useTranslate(); const { theme, setTheme } = useTheme(); - const dataProvider = useDataProvider(); - const notify = useNotify(); + const persist = usePersistPreference(); const handleSetTheme = (value: Theme) => { setTheme(value); - dataProvider - .updatePreferences({ theme: value }) - .catch((e) => - notify( - typeof e?.message === "string" - ? e?.message - : "ra.notification.http_error", - { type: "error" }, - ), - ); + persist({ theme: value }); }; return ( diff --git a/src/components/atomic-crm/types.ts b/src/components/atomic-crm/types.ts index 6d1d6ff66c..1dd1bce38f 100644 --- a/src/components/atomic-crm/types.ts +++ b/src/components/atomic-crm/types.ts @@ -1,6 +1,7 @@ import type { Identifier, RaRecord } from "ra-core"; import type { ComponentType } from "react"; +import type { Theme } from "@/components/admin/theme-context"; import type { COMPANY_CREATED, CONTACT_CREATED, @@ -9,6 +10,11 @@ import type { DEAL_NOTE_CREATED, } from "./consts"; +export type UserPreferences = { + theme?: Theme; + locale?: string; +}; + export type SignUpData = { email: string; password: string; @@ -33,6 +39,7 @@ export type Sale = { avatar?: RAFile; disabled?: boolean; user_id: string; + preferences?: UserPreferences; /** * This is a copy of the user's email, to make it easier to handle by react admin diff --git a/src/test/StoryWrapper.tsx b/src/test/StoryWrapper.tsx index f9f1a16fb7..f18457383a 100644 --- a/src/test/StoryWrapper.tsx +++ b/src/test/StoryWrapper.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-refresh/only-export-components */ -import { memoryStore, type AuthProvider } from "ra-core"; +import { memoryStore, type AuthProvider, type I18nProvider } from "ra-core"; import { useEffect, useMemo, type ReactNode } from "react"; import { MemoryRouter } from "react-router"; import cloneDeep from "lodash/cloneDeep"; @@ -79,12 +79,14 @@ export const StoryWrapper = ({ children, data, dataProvider: dataProviderOverrides, + i18nProvider = testI18nProvider, initialEntries, silent = import.meta.env.MODE === "test", }: { children: ReactNode; data?: Partial; dataProvider?: Partial>; + i18nProvider?: I18nProvider; initialEntries?: string[]; silent?: boolean; }) => { @@ -109,7 +111,7 @@ export const StoryWrapper = ({ <>{children}} store={store} disableTelemetry diff --git a/supabase/migrations/20260401133342_add_sales_preferences.sql b/supabase/migrations/20260803084609_add_sales_preferences.sql similarity index 65% rename from supabase/migrations/20260401133342_add_sales_preferences.sql rename to supabase/migrations/20260803084609_add_sales_preferences.sql index e56d6856c6..b85383eddc 100644 --- a/supabase/migrations/20260401133342_add_sales_preferences.sql +++ b/supabase/migrations/20260803084609_add_sales_preferences.sql @@ -8,3 +8,5 @@ create policy "Enable self-update for authenticated users" using ((user_id = auth.uid())) with check ((user_id = auth.uid())); +revoke update on table "public"."sales" from "authenticated"; +grant update ("preferences") on table "public"."sales" to "authenticated"; diff --git a/supabase/schemas/06_grants.sql b/supabase/schemas/06_grants.sql index 865c9dbc74..348569c3ed 100644 --- a/supabase/schemas/06_grants.sql +++ b/supabase/schemas/06_grants.sql @@ -90,6 +90,9 @@ grant all on table public.sales to anon; grant all on table public.sales to authenticated; grant all on table public.sales to service_role; +revoke update on table public.sales from authenticated; +grant update (preferences) on table public.sales to authenticated; + grant all on table public.tags to anon; grant all on table public.tags to authenticated; grant all on table public.tags to service_role; From 9666b3ea47922ae5501effc2ef30dde3e56e5632 Mon Sep 17 00:00:00 2001 From: Jerome Date: Mon, 3 Aug 2026 13:09:49 +0000 Subject: [PATCH 05/13] Harden preference persistence: validate on read, guard the column by trigger Second pass on the review feedback for #246. Validate the jsonb on read. The column is free-form jsonb an authenticated user can PATCH directly through PostgREST, and the theme reaches classList.add, which throws InvalidCharacterError on a value containing whitespace. Since ThemeProvider sits above the layout error boundary, that blanked the app on every load with no in-app recovery. Values are now parsed field by field, so one bad key cannot discard a valid sibling. Guard the column by trigger, not only by grant ordering. The revoke sat three lines under the `grant all` it undoes, so re-adding or regenerating that line would silently restore table-wide UPDATE and with it self-promotion to administrator. enforce_sales_self_update_scope compares every column except preferences and rejects the write, which holds regardless of grant order and protects columns added later by construction. auth.uid() is null for the service_role, so the users edge function is unaffected. Verified against a simulated grant regression. Also: - wrap Postgrest failures in HttpError with the response status, so ra-core's checkError sees a 401 and logs the user out instead of showing raw backend text - route the fakerest twin through the wrapped provider so the sales lifecycle callback fires, and fix its identity guard, which could never trigger because fakerest getIdentity falls back to id 0, the administrator row - run the two login prefetches concurrently instead of serially --- .../providers/fakerest/dataProvider.ts | 18 ++++---- .../providers/supabase/dataProvider.ts | 30 ++++++++----- src/components/atomic-crm/root/CRM.tsx | 33 ++++++++------- .../atomic-crm/root/preferences.test.ts | 42 +++++++++++++++++++ src/components/atomic-crm/root/preferences.ts | 21 ++++++++++ .../20260803084609_add_sales_preferences.sql | 20 +++++++++ supabase/schemas/02_functions.sql | 12 ++++++ supabase/schemas/04_triggers.sql | 5 +++ supabase/schemas/06_grants.sql | 4 ++ 9 files changed, 154 insertions(+), 31 deletions(-) create mode 100644 src/components/atomic-crm/root/preferences.test.ts diff --git a/src/components/atomic-crm/providers/fakerest/dataProvider.ts b/src/components/atomic-crm/providers/fakerest/dataProvider.ts index 46b3a7ba81..6fca9a9264 100644 --- a/src/components/atomic-crm/providers/fakerest/dataProvider.ts +++ b/src/components/atomic-crm/providers/fakerest/dataProvider.ts @@ -21,6 +21,7 @@ import type { UserPreferences, } from "../../types"; import type { ConfigurationContextValue } from "../../root/ConfigurationContext"; +import { parseUserPreferences } from "../../root/preferences"; import { getActivityLog } from "../commons/activity"; import { getCompanyAvatar } from "../commons/getCompanyAvatar"; import { getContactAvatar } from "../commons/getContactAvatar"; @@ -319,23 +320,26 @@ export const createDataProvider = ({ }, getPreferences: async (): Promise => { const identity = await getIdentity(); - if (!identity) return {}; - const { data } = await baseDataProvider.getOne("sales", { + if (!identity?.id) return {}; + const { data } = await dataProvider.getOne("sales", { id: identity.id, }); - return data?.preferences ?? {}; + return parseUserPreferences(data?.preferences); }, updatePreferences: async ( patch: Partial, ): Promise => { const identity = await getIdentity(); - if (!identity) return patch; - const { data: sale } = await baseDataProvider.getOne("sales", { + if (!identity?.id) return patch; + const { data: sale } = await dataProvider.getOne("sales", { id: identity.id, }); if (!sale) return patch; - const preferences = { ...(sale.preferences ?? {}), ...patch }; - await baseDataProvider.update("sales", { + const preferences = { + ...parseUserPreferences(sale.preferences), + ...patch, + }; + await dataProvider.update("sales", { id: identity.id, data: { preferences }, previousData: sale, diff --git a/src/components/atomic-crm/providers/supabase/dataProvider.ts b/src/components/atomic-crm/providers/supabase/dataProvider.ts index 5367d26c07..fa1dd150d0 100644 --- a/src/components/atomic-crm/providers/supabase/dataProvider.ts +++ b/src/components/atomic-crm/providers/supabase/dataProvider.ts @@ -1,5 +1,6 @@ import { supabaseDataProvider } from "ra-supabase-core"; import { + HttpError, withLifecycleCallbacks, type DataProvider, type GetListParams, @@ -17,6 +18,7 @@ import type { UserPreferences, } from "../../types"; import type { ConfigurationContextValue } from "../../root/ConfigurationContext"; +import { parseUserPreferences } from "../../root/preferences"; import { ATTACHMENTS_BUCKET } from "../commons/attachments"; import { getIsInitialized } from "./authProvider"; import { getSupabaseClient } from "./supabase"; @@ -245,36 +247,44 @@ const getDataProviderWithCustomMethods = () => { async getPreferences(): Promise { const { data: session } = await getSupabaseClient().auth.getSession(); if (!session?.session?.user) return {}; - const { data, error } = await getSupabaseClient() + const { data, error, status } = await getSupabaseClient() .from("sales") .select("preferences") .match({ user_id: session.session.user.id }) .maybeSingle(); if (error) { console.error("getPreferences.error", error); - throw error; + throw new HttpError("Failed to read preferences", status, error); } - return (data?.preferences as UserPreferences) ?? {}; + return parseUserPreferences(data?.preferences); }, async updatePreferences( patch: Partial, ): Promise { const { data: session } = await getSupabaseClient().auth.getSession(); if (!session?.session?.user) return patch; - const { data: sale, error: readError } = await getSupabaseClient() + const { + data: sale, + error: readError, + status: readStatus, + } = await getSupabaseClient() .from("sales") .select("preferences") .match({ user_id: session.session.user.id }) .maybeSingle(); if (readError) { console.error("updatePreferences.error", readError); - throw readError; + throw new HttpError( + "Failed to update preferences", + readStatus, + readError, + ); } const preferences = { - ...((sale?.preferences as UserPreferences) ?? {}), + ...parseUserPreferences(sale?.preferences), ...patch, }; - const { data, error } = await getSupabaseClient() + const { data, error, status } = await getSupabaseClient() .from("sales") .update({ preferences }) .match({ user_id: session.session.user.id }) @@ -282,12 +292,12 @@ const getDataProviderWithCustomMethods = () => { .maybeSingle(); if (error) { console.error("updatePreferences.error", error); - throw error; + throw new HttpError("Failed to update preferences", status, error); } if (!data) { - throw new Error("Failed to update preferences"); + throw new HttpError("Failed to update preferences", status); } - return data.preferences as UserPreferences; + return parseUserPreferences(data.preferences); }, } satisfies DataProvider; }; diff --git a/src/components/atomic-crm/root/CRM.tsx b/src/components/atomic-crm/root/CRM.tsx index 5d28552873..dd858c266e 100644 --- a/src/components/atomic-crm/root/CRM.tsx +++ b/src/components/atomic-crm/root/CRM.tsx @@ -171,21 +171,26 @@ export const CRM = ({ // on login, pre-fetch the configuration and preferences to avoid // a flickering when accessing the app for the first time const prefetchConfigAndPreferences = useCallback(async () => { - try { - const config = await dataProvider.getConfiguration(); - if (Object.keys(config).length > 0) { - store.setItem(CONFIGURATION_STORE_KEY, config); + const loadConfiguration = async () => { + try { + const config = await dataProvider.getConfiguration(); + if (Object.keys(config).length > 0) { + store.setItem(CONFIGURATION_STORE_KEY, config); + } + } catch { + // Non-critical: config will load via useConfigurationLoader } - } catch { - // Non-critical: config will load via useConfigurationLoader - } - try { - const preferences = await dataProvider.getPreferences(); - if (preferences.theme) store.setItem("theme", preferences.theme); - if (preferences.locale) store.setItem("locale", preferences.locale); - } catch { - // Non-critical: preferences will load via usePreferencesLoader - } + }; + const loadPreferences = async () => { + try { + const preferences = await dataProvider.getPreferences(); + if (preferences.theme) store.setItem("theme", preferences.theme); + if (preferences.locale) store.setItem("locale", preferences.locale); + } catch { + // Non-critical: preferences will load via usePreferencesLoader + } + }; + await Promise.all([loadConfiguration(), loadPreferences()]); }, [dataProvider, store]); const wrappedAuthProvider = useMemo( diff --git a/src/components/atomic-crm/root/preferences.test.ts b/src/components/atomic-crm/root/preferences.test.ts new file mode 100644 index 0000000000..0f9ae21be0 --- /dev/null +++ b/src/components/atomic-crm/root/preferences.test.ts @@ -0,0 +1,42 @@ +import { parseUserPreferences } from "./preferences"; + +describe("parseUserPreferences", () => { + it("keeps valid values", () => { + expect(parseUserPreferences({ theme: "dark", locale: "fr" })).toEqual({ + theme: "dark", + locale: "fr", + }); + expect(parseUserPreferences({ locale: "en-GB" })).toEqual({ + locale: "en-GB", + }); + }); + + it("drops a theme that would break classList.add", () => { + expect(parseUserPreferences({ theme: "a b" })).toEqual({}); + expect(parseUserPreferences({ theme: "" })).toEqual({}); + expect(parseUserPreferences({ theme: {} })).toEqual({}); + expect(parseUserPreferences({ theme: "purple" })).toEqual({}); + }); + + it("drops one malformed field without discarding its valid sibling", () => { + expect(parseUserPreferences({ theme: "a b", locale: "fr" })).toEqual({ + locale: "fr", + }); + expect(parseUserPreferences({ theme: "dark", locale: 42 })).toEqual({ + theme: "dark", + }); + }); + + it("strips unknown keys", () => { + expect( + parseUserPreferences({ theme: "light", sneaky: "