From 398661d3406743206fb71216fd85e1075938ba75 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Mon, 15 Jun 2026 18:55:10 +0200
Subject: [PATCH] feat(settings): add location sharing controls to
Personalization
Adds Location section to Settings Personalization tab: opt-in switch,
manual text input, and device-geolocation button with client-side
reverse-geocoding (BigDataCloud, admin-overridable endpoint). Access
is gated by startupConfig.location.enabled; enabling it makes the
Personalization tab visible even without memory opt-out access.
---
client/src/components/Nav/Settings.tsx | 4 +-
.../Nav/SettingsTabs/Personalization.tsx | 149 ++++++++++++++++--
.../__tests__/Personalization.spec.tsx | 66 ++++++++
client/src/hooks/usePersonalizationAccess.ts | 6 +-
client/src/locales/en/translation.json | 8 +
client/src/utils/geocode.ts | 59 +++++++
6 files changed, 277 insertions(+), 15 deletions(-)
create mode 100644 client/src/components/Nav/SettingsTabs/__tests__/Personalization.spec.tsx
create mode 100644 client/src/utils/geocode.ts
diff --git a/client/src/components/Nav/Settings.tsx b/client/src/components/Nav/Settings.tsx
index f5574d5616..2a73e7317c 100644
--- a/client/src/components/Nav/Settings.tsx
+++ b/client/src/components/Nav/Settings.tsx
@@ -34,7 +34,8 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
const localize = useLocalize();
const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
const tabRefs = useRef({});
- const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
+ const { hasAnyPersonalizationFeature, hasMemoryOptOut, hasLocationSharing } =
+ usePersonalizationAccess();
const aboutEnabled = startupConfig?.interface?.buildInfo !== false;
useEffect(() => {
@@ -254,6 +255,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
diff --git a/client/src/components/Nav/SettingsTabs/Personalization.tsx b/client/src/components/Nav/SettingsTabs/Personalization.tsx
index f9e43dc6f5..7d5211205f 100644
--- a/client/src/components/Nav/SettingsTabs/Personalization.tsx
+++ b/client/src/components/Nav/SettingsTabs/Personalization.tsx
@@ -1,51 +1,125 @@
import { useState, useEffect } from 'react';
-import { Switch, useToastContext } from '@librechat/client';
-import { useGetUserQuery, useUpdateMemoryPreferencesMutation } from '~/data-provider';
+import { Switch, Input, useToastContext } from '@librechat/client';
+import type { TUserLocation } from 'librechat-data-provider';
+import {
+ useGetUserQuery,
+ useGetStartupConfig,
+ useUpdateUserLocationMutation,
+ useUpdateMemoryPreferencesMutation,
+} from '~/data-provider';
+import { reverseGeocode, getCurrentPosition } from '~/utils/geocode';
import { useLocalize } from '~/hooks';
interface PersonalizationProps {
hasMemoryOptOut: boolean;
+ hasLocationSharing: boolean;
hasAnyPersonalizationFeature: boolean;
}
export default function Personalization({
hasMemoryOptOut,
+ hasLocationSharing,
hasAnyPersonalizationFeature,
}: PersonalizationProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: user } = useGetUserQuery();
+ const { data: startupConfig } = useGetStartupConfig();
+
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
+ const [locationEnabled, setLocationEnabled] = useState(false);
+ const [manualLocation, setManualLocation] = useState('');
+ const [detecting, setDetecting] = useState(false);
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
onSuccess: () => {
- showToast({
- message: localize('com_ui_preferences_updated'),
- status: 'success',
- });
+ showToast({ message: localize('com_ui_preferences_updated'), status: 'success' });
},
onError: () => {
- showToast({
- message: localize('com_ui_error_updating_preferences'),
- status: 'error',
- });
- // Revert the toggle on error
+ showToast({ message: localize('com_ui_error_updating_preferences'), status: 'error' });
setReferenceSavedMemories((prev) => !prev);
},
});
- // Initialize state from user data
+ const updateLocationMutation = useUpdateUserLocationMutation({
+ onSuccess: () => {
+ showToast({ message: localize('com_ui_preferences_updated'), status: 'success' });
+ },
+ onError: () => {
+ showToast({ message: localize('com_ui_error_updating_preferences'), status: 'error' });
+ },
+ });
+
useEffect(() => {
if (user?.personalization?.memories !== undefined) {
setReferenceSavedMemories(user.personalization.memories);
}
}, [user?.personalization?.memories]);
+ useEffect(() => {
+ const loc = user?.personalization?.location;
+ if (loc) {
+ setLocationEnabled(loc.enabled ?? false);
+ setManualLocation(loc.manual ?? '');
+ }
+ }, [user?.personalization?.location]);
+
const handleMemoryToggle = (checked: boolean) => {
setReferenceSavedMemories(checked);
updateMemoryPreferencesMutation.mutate({ memories: checked });
};
+ const persistLocation = (payload: TUserLocation) => updateLocationMutation.mutate(payload);
+
+ const handleLocationToggle = (checked: boolean) => {
+ setLocationEnabled(checked);
+ persistLocation({ enabled: checked, source: 'manual', manual: manualLocation || undefined });
+ };
+
+ const handleManualBlur = () => {
+ if (!locationEnabled && !manualLocation) {
+ return;
+ }
+ persistLocation({
+ enabled: locationEnabled,
+ source: 'manual',
+ manual: manualLocation || undefined,
+ });
+ };
+
+ const handleUseDeviceLocation = async () => {
+ setDetecting(true);
+ try {
+ const position = await getCurrentPosition();
+ const resolved = await reverseGeocode(
+ position.coords.latitude,
+ position.coords.longitude,
+ startupConfig?.location?.geocoder?.endpoint,
+ );
+ setLocationEnabled(true);
+ persistLocation({
+ enabled: true,
+ source: 'auto',
+ place: resolved.place,
+ coordinates: resolved.coordinates,
+ timezone: resolved.timezone,
+ });
+ } catch (error) {
+ const denied =
+ typeof GeolocationPositionError !== 'undefined' &&
+ error instanceof GeolocationPositionError &&
+ error.code === error.PERMISSION_DENIED;
+ showToast({
+ message: localize(
+ denied ? 'com_ui_location_permission_denied' : 'com_ui_location_unavailable',
+ ),
+ status: 'warning',
+ });
+ } finally {
+ setDetecting(false);
+ }
+ };
+
if (!hasAnyPersonalizationFeature) {
return (
@@ -56,7 +130,6 @@ export default function Personalization({
return (
- {/* Memory Settings Section */}
{hasMemoryOptOut && (
<>
@@ -85,6 +158,56 @@ export default function Personalization({
>
)}
+
+ {hasLocationSharing && (
+ <>
+
+
{localize('com_ui_location')}
+
+
+
+
+
+ {localize('com_ui_share_location_with_agents')}
+
+
+ {localize('com_ui_share_location_with_agents_description')}
+
+
+
+
+
+
+
+ setManualLocation(e.target.value)}
+ onBlur={handleManualBlur}
+ aria-label={localize('com_ui_set_location_manually')}
+ className="flex h-10 w-full px-3 py-2"
+ />
+
+
+ >
+ )}
);
}
diff --git a/client/src/components/Nav/SettingsTabs/__tests__/Personalization.spec.tsx b/client/src/components/Nav/SettingsTabs/__tests__/Personalization.spec.tsx
new file mode 100644
index 0000000000..0963f7c5ba
--- /dev/null
+++ b/client/src/components/Nav/SettingsTabs/__tests__/Personalization.spec.tsx
@@ -0,0 +1,66 @@
+import { render, screen, fireEvent, waitFor } from 'test/layout-test-utils';
+import * as endpointQueries from '~/data-provider/Endpoints/queries';
+import * as memoriesQueries from '~/data-provider/Memories/queries';
+import * as locationQueries from '~/data-provider/Location/queries';
+import * as authQueries from '~/data-provider/Auth/queries';
+import Personalization from '../Personalization';
+
+const mockMutate = jest.fn();
+
+beforeEach(() => {
+ mockMutate.mockClear();
+
+ jest
+ .spyOn(authQueries, 'useGetUserQuery')
+ // @ts-expect-error partial mock
+ .mockReturnValue({ data: { personalization: {} } });
+
+ jest
+ .spyOn(endpointQueries, 'useGetStartupConfig')
+ // @ts-expect-error partial mock
+ .mockReturnValue({ data: { location: { enabled: true } } });
+
+ jest
+ .spyOn(memoriesQueries, 'useUpdateMemoryPreferencesMutation')
+ // @ts-expect-error partial mock
+ .mockReturnValue({ mutate: jest.fn(), isLoading: false });
+
+ jest
+ .spyOn(locationQueries, 'useUpdateUserLocationMutation')
+ // @ts-expect-error partial mock
+ .mockReturnValue({ mutate: mockMutate, isLoading: false });
+
+ // @ts-expect-error test stub
+ global.navigator.geolocation = {
+ getCurrentPosition: (success: PositionCallback) =>
+ success({ coords: { latitude: 48.85, longitude: 2.35 } } as GeolocationPosition),
+ };
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('Personalization location section', () => {
+ it('renders the location toggle and manual field when enabled', () => {
+ render(
+
,
+ );
+ expect(screen.getByText('Share my location with agents')).toBeInTheDocument();
+ expect(screen.getByLabelText('Set location manually')).toBeInTheDocument();
+ expect(screen.getByText('Use my device location')).toBeInTheDocument();
+ });
+
+ it('persists a manual location on blur', async () => {
+ render(
+
,
+ );
+ const input = screen.getByLabelText('Set location manually');
+ fireEvent.change(input, { target: { value: 'Tokyo, Japan' } });
+ fireEvent.blur(input);
+ await waitFor(() => expect(mockMutate).toHaveBeenCalled());
+ expect(mockMutate).toHaveBeenCalledWith(
+ expect.objectContaining({ manual: 'Tokyo, Japan', source: 'manual' }),
+ );
+ });
+});
diff --git a/client/src/hooks/usePersonalizationAccess.ts b/client/src/hooks/usePersonalizationAccess.ts
index 9a419f4a63..e1069d6531 100644
--- a/client/src/hooks/usePersonalizationAccess.ts
+++ b/client/src/hooks/usePersonalizationAccess.ts
@@ -1,16 +1,20 @@
import { PermissionTypes, Permissions } from 'librechat-data-provider';
+import { useGetStartupConfig } from '~/data-provider';
import useHasAccess from './Roles/useHasAccess';
export default function usePersonalizationAccess() {
+ const { data: startupConfig } = useGetStartupConfig();
const hasMemoryOptOut = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.OPT_OUT,
});
- const hasAnyPersonalizationFeature = hasMemoryOptOut;
+ const hasLocationSharing = startupConfig?.location?.enabled === true;
+ const hasAnyPersonalizationFeature = hasMemoryOptOut || hasLocationSharing;
return {
hasMemoryOptOut,
+ hasLocationSharing,
hasAnyPersonalizationFeature,
};
}
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 03f924373a..cffb19a898 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1184,6 +1184,14 @@
"com_ui_link_refreshed": "Link refreshed",
"com_ui_live": "live",
"com_ui_load_more": "Load more",
+ "com_ui_location": "Location",
+ "com_ui_location_detecting": "Detecting your location…",
+ "com_ui_location_permission_denied": "Location permission was denied. You can enter a location manually instead.",
+ "com_ui_location_unavailable": "Could not detect your location. You can enter one manually instead.",
+ "com_ui_set_location_manually": "Set location manually",
+ "com_ui_share_location_with_agents": "Share my location with agents",
+ "com_ui_share_location_with_agents_description": "Let agents use the get_location tool to read your location for language, units, and regional context",
+ "com_ui_use_device_location": "Use my device location",
"com_ui_loading": "Loading...",
"com_ui_locked": "Locked",
"com_ui_logo": "{{0}} Logo",
diff --git a/client/src/utils/geocode.ts b/client/src/utils/geocode.ts
new file mode 100644
index 0000000000..a8114ca94f
--- /dev/null
+++ b/client/src/utils/geocode.ts
@@ -0,0 +1,59 @@
+const DEFAULT_ENDPOINT = 'https://api.bigdatacloud.net/data/reverse-geocode-client';
+
+const round = (n: number) => Math.round(n * 100) / 100;
+
+export interface ResolvedLocation {
+ place?: string;
+ coordinates: { latitude: number; longitude: number };
+ timezone?: string;
+}
+
+/**
+ * Reverse-geocodes coordinates client-side via the configured (CORS) endpoint.
+ * Always returns rounded coordinates + timezone; `place` is omitted on failure.
+ */
+export async function reverseGeocode(
+ latitude: number,
+ longitude: number,
+ endpoint: string = DEFAULT_ENDPOINT,
+): Promise
{
+ const coordinates = { latitude: round(latitude), longitude: round(longitude) };
+ let timezone: string | undefined;
+ try {
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ } catch {
+ timezone = undefined;
+ }
+
+ try {
+ const url = `${endpoint}?latitude=${latitude}&longitude=${longitude}&localityLanguage=en`;
+ const response = await fetch(url);
+ if (!response.ok) {
+ return { coordinates, timezone };
+ }
+ const data = await response.json();
+ const place = [data.city || data.locality, data.principalSubdivision, data.countryName]
+ .filter((part: unknown): part is string => typeof part === 'string' && part.length > 0)
+ .join(', ');
+ return { place: place || undefined, coordinates, timezone };
+ } catch {
+ return { coordinates, timezone };
+ }
+}
+
+/**
+ * Promisified navigator.geolocation.getCurrentPosition.
+ */
+export function getCurrentPosition(): Promise {
+ return new Promise((resolve, reject) => {
+ if (typeof navigator === 'undefined' || !navigator.geolocation) {
+ reject(new Error('Geolocation is not supported'));
+ return;
+ }
+ navigator.geolocation.getCurrentPosition(resolve, reject, {
+ enableHighAccuracy: false,
+ timeout: 10000,
+ maximumAge: 600000,
+ });
+ });
+}