From 0e6a14e815f038a6cecacbe7a8a75a7a2c2c7621 Mon Sep 17 00:00:00 2001 From: Ihor Dykhta Date: Sat, 22 Aug 2026 12:15:24 +0300 Subject: [PATCH 1/3] fix: google drive provider prevent auto popup Signed-off-by: Ihor Dykhta --- .../google-drive/google-drive-provider.js | 36 +++++++------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js b/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js index 2a0485534f..53e48026e0 100644 --- a/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js +++ b/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js @@ -80,7 +80,9 @@ export default class GoogleDriveProvider extends Provider { async getAccessToken() { const stored = this._readStorage(); - // Reuse cached token only while expiresAt says it is still valid + // Reuse cached token only while expiresAt says it is still valid. + // Do not call GIS here: requestAccessToken always opens a popup, and + // CloudTile calls getUser() on mount (Load From Storage / Save Map). if (stored?.token && !this._isExpired(stored)) { const scope = stored.scope || ''; if (scope && !scope.split(/\s+/).includes(DRIVE_SCOPE)) { @@ -92,27 +94,7 @@ export default class GoogleDriveProvider extends Provider { return this._accessToken; } - const hadToken = Boolean(this._accessToken || stored?.token); this._accessToken = null; - - // Silent refresh after expiry (or if memory still held a stale token) - if (hadToken && this.clientId) { - try { - await this._ensureTokenClient(); - const tokenResponse = await this._requestAccessToken({prompt: ''}); - if (!this._hasDriveScope(tokenResponse)) { - this._clearStorage(); - this._accessToken = null; - return null; - } - return tokenResponse.access_token; - } catch (err) { - this._clearStorage(); - this._accessToken = null; - return null; - } - } - return null; } @@ -151,9 +133,15 @@ export default class GoogleDriveProvider extends Provider { } await this._ensureTokenClient(); - // Must be called from a user gesture so the consent popup is not blocked. + // GIS TokenClient always opens a popup; only login() (user click) may call it. + // Prior session: prompt '' refreshes without the account picker (popup may flash + // and auto-close). First login: select_account so the user can pick an account. // Google may grant Sign-In scopes but not Drive (granular consent) — verify and re-ask. - let tokenResponse = await this._requestAccessToken({prompt: 'select_account'}); + const stored = this._readStorage(); + const hadSession = Boolean(stored?.token || stored?.user); + let tokenResponse = await this._requestAccessToken({ + prompt: hadSession ? '' : 'select_account' + }); if (!this._hasDriveScope(tokenResponse)) { tokenResponse = await this._requestAccessToken({ prompt: 'consent', @@ -425,7 +413,7 @@ export default class GoogleDriveProvider extends Provider { this._tokenClient.requestAccessToken(overrides); }); - // Queue so overlapping silent refresh + login cannot overwrite callbacks. + // Queue so overlapping login requests cannot overwrite GIS callbacks. const next = this._tokenRequestChain.then(run, run); this._tokenRequestChain = next.then( () => undefined, From 27f0f5fcae69b490cfa142d001e76c681c38deba Mon Sep 17 00:00:00 2001 From: Ihor Dykhta Date: Sun, 23 Aug 2026 11:32:39 +0300 Subject: [PATCH 2/3] google drive fixes Signed-off-by: Ihor Dykhta --- .../google-drive/google-drive-provider.js | 79 ++++++++++++------- src/components/src/modals/cloud-tile.tsx | 21 ++++- 2 files changed, 68 insertions(+), 32 deletions(-) diff --git a/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js b/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js index 53e48026e0..2c45baa365 100644 --- a/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js +++ b/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js @@ -13,10 +13,11 @@ const MIME_JSON = 'application/json'; const MIME_PNG = 'image/png'; const DRIVE_API = 'https://www.googleapis.com/drive/v3'; const DRIVE_UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3'; -const USERINFO_API = 'https://www.googleapis.com/oauth2/v3/userinfo'; const GIS_SCRIPT_URL = 'https://accounts.google.com/gsi/client'; const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.file'; -const SCOPES = [DRIVE_SCOPE, 'openid', 'profile', 'email'].join(' '); +// Drive only — openid/profile/email adds a separate "Sign in to Kepler.gl" step +// that races with Drive consent and often ends as popup_closed. User info comes +// from Drive about.get instead. const PRIVATE_STORAGE_ENABLED = true; const SHARING_ENABLED = false; @@ -136,18 +137,13 @@ export default class GoogleDriveProvider extends Provider { // GIS TokenClient always opens a popup; only login() (user click) may call it. // Prior session: prompt '' refreshes without the account picker (popup may flash // and auto-close). First login: select_account so the user can pick an account. - // Google may grant Sign-In scopes but not Drive (granular consent) — verify and re-ask. + // One GIS request only — a second requestAccessToken (Drive consent retry) + // races with the still-open first popup and surfaces as popup_closed. const stored = this._readStorage(); const hadSession = Boolean(stored?.token || stored?.user); - let tokenResponse = await this._requestAccessToken({ + const tokenResponse = await this._requestAccessToken({ prompt: hadSession ? '' : 'select_account' }); - if (!this._hasDriveScope(tokenResponse)) { - tokenResponse = await this._requestAccessToken({ - prompt: 'consent', - scope: DRIVE_SCOPE - }); - } if (!this._hasDriveScope(tokenResponse)) { throw new Error( 'Google Drive access was not granted. On the consent screen, allow Google Drive / See, edit, create, and delete only the specific Google Drive files you use with this app.' @@ -164,12 +160,9 @@ export default class GoogleDriveProvider extends Provider { } async logout() { - const token = this._accessToken || this._readStorage()?.token; - if (token && window.google?.accounts?.oauth2?.revoke) { - await new Promise(resolve => { - window.google.accounts.oauth2.revoke(token, resolve); - }); - } + // Do not GIS-revoke: revoke forces a full re-consent on the next login + // ("Kepler.gl wants access…") and that multi-step popup often closes + // without delivering a token. Clearing the local session is enough. this._accessToken = null; this._folderId = null; this._shareUrl = null; @@ -365,7 +358,7 @@ export default class GoogleDriveProvider extends Provider { // callback is set per request in _requestAccessToken this._tokenClient = window.google.accounts.oauth2.initTokenClient({ client_id: this.clientId, - scope: SCOPES, + scope: DRIVE_SCOPE, callback: () => {} }); } @@ -385,9 +378,23 @@ export default class GoogleDriveProvider extends Provider { _requestAccessToken({prompt, scope} = {}) { const run = () => new Promise((resolve, reject) => { + let settled = false; + let popupClosedTimer = null; + + const settle = (fn, value) => { + if (settled) { + return; + } + settled = true; + if (popupClosedTimer) { + clearTimeout(popupClosedTimer); + } + fn(value); + }; + this._tokenClient.callback = response => { if (response.error) { - reject(new Error(response.error_description || response.error)); + settle(reject, new Error(response.error_description || response.error)); return; } this._accessToken = response.access_token; @@ -398,10 +405,27 @@ export default class GoogleDriveProvider extends Provider { scope: response.scope, user: this._readStorage()?.user }); - resolve(response); + settle(resolve, response); }; + let silentRetry = false; this._tokenClient.error_callback = error => { - reject(new Error(error?.type || 'Google OAuth error')); + // After a completed consent, GIS often fires popup_closed instead of + // callback. Ask once with prompt '' to pick up the grant (brief flash). + if (error?.type === 'popup_closed' && !silentRetry) { + silentRetry = true; + this._tokenClient.requestAccessToken({prompt: ''}); + return; + } + if (error?.type === 'popup_closed') { + popupClosedTimer = setTimeout(() => { + settle( + reject, + new Error('Google sign-in was closed before finishing. Click Login to try again.') + ); + }, 1500); + return; + } + settle(reject, new Error(error?.type || 'Google OAuth error')); }; const overrides = {}; if (prompt !== undefined) { @@ -423,17 +447,12 @@ export default class GoogleDriveProvider extends Provider { } async _fetchUser(token) { - const response = await fetch(USERINFO_API, { - headers: {Authorization: `Bearer ${token}`} - }); - if (!response.ok) { - throw new Error('Failed to fetch Google user profile'); - } - const profile = await response.json(); + const data = await this._driveFetch(`${DRIVE_API}/about?fields=user`, {token}); + const user = data.user || {}; return { - name: profile.name || profile.email || 'Google User', - email: profile.email || '', - thumbnail: profile.picture + name: user.displayName || user.emailAddress || 'Google User', + email: user.emailAddress || '', + thumbnail: user.photoLink }; } diff --git a/src/components/src/modals/cloud-tile.tsx b/src/components/src/modals/cloud-tile.tsx index 96c9a98447..13ca77d76c 100644 --- a/src/components/src/modals/cloud-tile.tsx +++ b/src/components/src/modals/cloud-tile.tsx @@ -174,8 +174,25 @@ const CloudTile: React.FC = ({provider, actionName}) => { return; } if (user) { - setProvider(provider); - return; + // Re-check on click: mount-time getUser() can go stale if the modal stays + // open past token expiry. If the session is gone, fall through to login() + // (this click is a user gesture, so an OAuth popup is allowed). + setError(null); + setIsLoading(true); + let currentUser: CloudUser | null = null; + try { + currentUser = await provider.getUser(); + } catch { + currentUser = null; + } + if (currentUser) { + setUser(currentUser); + setIsLoading(false); + setProvider(provider); + return; + } + setUser(null); + setIsLoading(false); } const nextUser = await onLogin(); if (!nextUser) { From fb8e05cf9fa7ed4718f2ca275686d3b37639560b Mon Sep 17 00:00:00 2001 From: Ihor Dykhta Date: Sun, 23 Aug 2026 13:40:12 +0300 Subject: [PATCH 3/3] fixes Signed-off-by: Ihor Dykhta --- .../google-drive/google-drive-provider.js | 37 ++++++++----------- .../cloud-storage-disclaimer.tsx | 24 ++++++++++++ src/components/src/modals/cloud-tile.tsx | 32 +++++++++++----- .../src/modals/load-storage-map.tsx | 12 +++++- src/components/src/modals/save-map-modal.tsx | 3 ++ src/components/src/modals/share-map-modal.tsx | 5 ++- src/localization/src/translations/ca.ts | 4 ++ src/localization/src/translations/cn.ts | 3 ++ src/localization/src/translations/en.ts | 4 ++ src/localization/src/translations/es.ts | 4 ++ src/localization/src/translations/fi.ts | 4 ++ src/localization/src/translations/ja.ts | 4 ++ src/localization/src/translations/pt.ts | 4 ++ src/localization/src/translations/ru.ts | 4 ++ 14 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 src/components/src/modals/cloud-components/cloud-storage-disclaimer.tsx diff --git a/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js b/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js index 2c45baa365..2c1b4bd03d 100644 --- a/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js +++ b/examples/demo-app/src/cloud-providers/google-drive/google-drive-provider.js @@ -379,16 +379,12 @@ export default class GoogleDriveProvider extends Provider { const run = () => new Promise((resolve, reject) => { let settled = false; - let popupClosedTimer = null; const settle = (fn, value) => { if (settled) { return; } settled = true; - if (popupClosedTimer) { - clearTimeout(popupClosedTimer); - } fn(value); }; @@ -397,6 +393,10 @@ export default class GoogleDriveProvider extends Provider { settle(reject, new Error(response.error_description || response.error)); return; } + // Ignore a token that arrives after the user closed the popup. + if (settled) { + return; + } this._accessToken = response.access_token; const expiresIn = Number(response.expires_in) || 3600; this._writeStorage({ @@ -407,25 +407,18 @@ export default class GoogleDriveProvider extends Provider { }); settle(resolve, response); }; - let silentRetry = false; this._tokenClient.error_callback = error => { - // After a completed consent, GIS often fires popup_closed instead of - // callback. Ask once with prompt '' to pick up the grant (brief flash). - if (error?.type === 'popup_closed' && !silentRetry) { - silentRetry = true; - this._tokenClient.requestAccessToken({prompt: ''}); - return; - } - if (error?.type === 'popup_closed') { - popupClosedTimer = setTimeout(() => { - settle( - reject, - new Error('Google sign-in was closed before finishing. Click Login to try again.') - ); - }, 1500); - return; - } - settle(reject, new Error(error?.type || 'Google OAuth error')); + // Treat popup_closed as cancel. Do not wait for a late token (leftover + // grant can look like success) and do not call requestAccessToken again + // (not a user gesture; a blocked popup can hang the token chain). + settle( + reject, + new Error( + error?.type === 'popup_closed' + ? 'Google sign-in was closed before finishing. Click Login to try again.' + : error?.type || 'Google OAuth error' + ) + ); }; const overrides = {}; if (prompt !== undefined) { diff --git a/src/components/src/modals/cloud-components/cloud-storage-disclaimer.tsx b/src/components/src/modals/cloud-components/cloud-storage-disclaimer.tsx new file mode 100644 index 0000000000..79f5d3469d --- /dev/null +++ b/src/components/src/modals/cloud-components/cloud-storage-disclaimer.tsx @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +// Copyright contributors to the kepler.gl project + +import React from 'react'; +import styled from 'styled-components'; +import {FormattedMessage} from '@kepler.gl/localization'; + +const StyledDisclaimer = styled.p.attrs({ + className: 'cloud-storage-disclaimer' +})` + margin-top: auto; + margin-bottom: 0; + padding-top: 16px; + font-size: 11px; + line-height: 1.4; + color: ${props => props.theme.subtextColor}; + max-width: 100%; +`; + +export const CloudStorageDisclaimer: React.FC = () => ( + + + +); diff --git a/src/components/src/modals/cloud-tile.tsx b/src/components/src/modals/cloud-tile.tsx index 13ca77d76c..8f0f269238 100644 --- a/src/components/src/modals/cloud-tile.tsx +++ b/src/components/src/modals/cloud-tile.tsx @@ -44,6 +44,9 @@ const StyledTileWrapper: IStyledComponent<'web', StyledTileWrapperProps> = style const StyledBox = styled(CenterVerticalFlexbox)` margin-right: 12px; position: relative; + width: 120px; + max-width: 120px; + flex: 0 0 120px; `; const StyledCloudName = styled.div` @@ -101,6 +104,12 @@ const NewTag = styled.div` export const StyledWarning = styled.span` color: ${props => props.theme.errorColor}; font-weight: ${props => props.theme.selectFontWeightBold}; + display: block; + width: 100%; + text-align: center; + overflow-wrap: break-word; + font-size: 11px; + line-height: 1.3; `; interface CloudTileProps { @@ -175,24 +184,27 @@ const CloudTile: React.FC = ({provider, actionName}) => { } if (user) { // Re-check on click: mount-time getUser() can go stale if the modal stays - // open past token expiry. If the session is gone, fall through to login() - // (this click is a user gesture, so an OAuth popup is allowed). + // open past token expiry. Null means the session is gone — fall through + // to login() (this click is a user gesture). A throw is a transient + // failure (e.g. Dropbox network); keep the cached user and do not + // force another provider login popup. setError(null); setIsLoading(true); - let currentUser: CloudUser | null = null; try { - currentUser = await provider.getUser(); + const currentUser = await provider.getUser(); + if (currentUser) { + setUser(currentUser); + setIsLoading(false); + setProvider(provider); + return; + } + setUser(null); + setIsLoading(false); } catch { - currentUser = null; - } - if (currentUser) { - setUser(currentUser); setIsLoading(false); setProvider(provider); return; } - setUser(null); - setIsLoading(false); } const nextUser = await onLogin(); if (!nextUser) { diff --git a/src/components/src/modals/load-storage-map.tsx b/src/components/src/modals/load-storage-map.tsx index b504ddc20b..ef0677f893 100644 --- a/src/components/src/modals/load-storage-map.tsx +++ b/src/components/src/modals/load-storage-map.tsx @@ -6,8 +6,15 @@ import CloudHeaderFactory from './cloud-components/cloud-header'; import {CloudMaps} from './cloud-components/cloud-maps'; import {useCloudListProvider} from '../hooks/use-cloud-list-provider'; import {ProviderSelect} from './cloud-components/provider-select'; +import {CloudStorageDisclaimer} from './cloud-components/cloud-storage-disclaimer'; import {FlexColContainer} from '../common/flex-container'; import {Provider, MapListItem} from '@kepler.gl/cloud-providers'; +import styled from 'styled-components'; + +const StyledLoadStorageMap = styled(FlexColContainer)` + flex: 1; + width: 100%; +`; LoadStorageMapFactory.deps = [CloudHeaderFactory]; @@ -48,7 +55,7 @@ function LoadStorageMapFactory(CloudHeader: ReturnType + {!currentProvider ? ( ) : ( @@ -63,7 +70,8 @@ function LoadStorageMapFactory(CloudHeader: ReturnType )} - + + ); }; diff --git a/src/components/src/modals/save-map-modal.tsx b/src/components/src/modals/save-map-modal.tsx index 41ffe5d64a..b745b5cbad 100644 --- a/src/components/src/modals/save-map-modal.tsx +++ b/src/components/src/modals/save-map-modal.tsx @@ -7,6 +7,7 @@ import ImageModalContainer, {ImageModalContainerProps} from './image-modal-conta import {FlexContainer} from '../common/flex-container'; import StatusPanel, {UploadAnimation} from './status-panel'; import {ProviderSelect} from './cloud-components/provider-select'; +import {CloudStorageDisclaimer} from './cloud-components/cloud-storage-disclaimer'; import {MAP_THUMBNAIL_DIMENSION, MAP_INFO_CHARACTER, dataTestIds} from '@kepler.gl/constants'; import { @@ -30,6 +31,7 @@ const StyledSaveMapModal = styled.div.attrs({ })` .save-map-modal-content { min-height: 400px; + display: flex; flex-direction: column; } @@ -255,6 +257,7 @@ function SaveMapModalFactory() { providerIcon={provider && provider.icon} /> ) : null} + diff --git a/src/components/src/modals/share-map-modal.tsx b/src/components/src/modals/share-map-modal.tsx index 6ac71889f7..083c341d39 100644 --- a/src/components/src/modals/share-map-modal.tsx +++ b/src/components/src/modals/share-map-modal.tsx @@ -17,6 +17,7 @@ import StatusPanel from './status-panel'; import {FormattedMessage} from '@kepler.gl/localization'; import {useCloudListProvider} from '../hooks/use-cloud-list-provider'; import {ProviderSelect} from './cloud-components/provider-select'; +import {CloudStorageDisclaimer} from './cloud-components/cloud-storage-disclaimer'; import {Provider} from '@kepler.gl/cloud-providers'; import {cleanupExportImage as cleanupExportImageAction} from '@kepler.gl/actions'; import {dataTestIds} from '@kepler.gl/constants'; @@ -73,10 +74,11 @@ const StyledShareMapModal = styled(StyledModalContent)` margin: 0 -72px -40px -72px; display: flex; flex-direction: column; + min-height: 500px; `; const StyledInnerDiv = styled.div` - min-height: 500px; + flex: 1; `; const UNDERLINE_TEXT_DECORATION_STYLE = {textDecoration: 'underline'}; @@ -192,6 +194,7 @@ export default function ShareMapUrlModalFactory() { )} ) : null} + diff --git a/src/localization/src/translations/ca.ts b/src/localization/src/translations/ca.ts index 8765a4b647..25d0c122a1 100644 --- a/src/localization/src/translations/ca.ts +++ b/src/localization/src/translations/ca.ts @@ -488,6 +488,10 @@ export default { title: 'Emmagatzematge al núvol', subtitle: 'Accedeix per desar el mapa al teu emmagatzematge al núvol' }, + providerSelect: { + disclaimer: + 'Inicieu la sessió amb el vostre compte. Els mapes es desen al vostre emmagatzematge personal del proveïdor que trieu, no a Kepler.gl.' + }, exportMap: { formatTitle: 'Format de mapa', formatSubtitle: 'Escull el format amb què vols exportar el teu mapa', diff --git a/src/localization/src/translations/cn.ts b/src/localization/src/translations/cn.ts index 7da4237a66..d79812555a 100644 --- a/src/localization/src/translations/cn.ts +++ b/src/localization/src/translations/cn.ts @@ -474,6 +474,9 @@ export default { title: '云存储', subtitle: '登录以将地图保存到个人云存储' }, + providerSelect: { + disclaimer: '请使用您自己的账号登录。地图保存在您所选提供商的个人云存储中,而非 Kepler.gl。' + }, exportMap: { formatTitle: '地图的格式', formatSubtitle: '选择导出地图的格式', diff --git a/src/localization/src/translations/en.ts b/src/localization/src/translations/en.ts index ae59de4415..cfdf7915da 100644 --- a/src/localization/src/translations/en.ts +++ b/src/localization/src/translations/en.ts @@ -539,6 +539,10 @@ export default { title: 'Cloud storage', subtitle: 'Login to save map to your personal cloud storage' }, + providerSelect: { + disclaimer: + 'You sign in with your own account. Maps are stored in your personal cloud storage with the provider you choose, not on Kepler.gl.' + }, exportMap: { formatTitle: 'Map format', formatSubtitle: 'Choose the format to export your map to', diff --git a/src/localization/src/translations/es.ts b/src/localization/src/translations/es.ts index ca59da7527..ca38601fa9 100644 --- a/src/localization/src/translations/es.ts +++ b/src/localization/src/translations/es.ts @@ -489,6 +489,10 @@ export default { title: 'Almacentage en la nube', subtitle: 'Acceder para guardar el mapa en teu almacenage en la nube' }, + providerSelect: { + disclaimer: + 'Inicia sesión con tu propia cuenta. Los mapas se guardan en tu almacenamiento personal del proveedor que elijas, no en Kepler.gl.' + }, exportMap: { formatTitle: 'Formato de mapa', formatSubtitle: 'Escoger el formato al que se desea exportar el mapa', diff --git a/src/localization/src/translations/fi.ts b/src/localization/src/translations/fi.ts index 571aba2d7d..04f1e24d9c 100644 --- a/src/localization/src/translations/fi.ts +++ b/src/localization/src/translations/fi.ts @@ -487,6 +487,10 @@ export default { title: 'Pilvitallennus', subtitle: 'Kirjaudu sisään pilvipalveluusi tallentaaksesi kartan' }, + providerSelect: { + disclaimer: + 'Kirjaudu omalla tililläsi. Kartat tallennetaan valitsemasi palvelun henkilökohtaiseen pilveen, ei Kepler.gl:ään.' + }, exportMap: { formatTitle: 'Kartan formaatti', formatSubtitle: 'Valitse formaatti, jossa viet kartan', diff --git a/src/localization/src/translations/ja.ts b/src/localization/src/translations/ja.ts index 7ec5b16457..0389c41128 100644 --- a/src/localization/src/translations/ja.ts +++ b/src/localization/src/translations/ja.ts @@ -486,6 +486,10 @@ export default { title: 'クラウドストレージ', subtitle: '地図を個人用クラウドストレージに保存するためにログインする' }, + providerSelect: { + disclaimer: + 'ご自身のアカウントでログインします。マップは選択したプロバイダーの個人ストレージに保存され、Kepler.gl 上には保存されません。' + }, exportMap: { formatTitle: '地図の形式', formatSubtitle: '地図の出力形式を選択します', diff --git a/src/localization/src/translations/pt.ts b/src/localization/src/translations/pt.ts index 8211b7df91..2b745adc0b 100644 --- a/src/localization/src/translations/pt.ts +++ b/src/localization/src/translations/pt.ts @@ -489,6 +489,10 @@ export default { title: 'Armazenamento Cloud', subtitle: 'Conecte-se para salvar o mapa para o seu armazenamento cloud pessoal' }, + providerSelect: { + disclaimer: + 'Entre com a sua própria conta. Os mapas são armazenados no seu armazenamento pessoal do provedor que você escolher, não no Kepler.gl.' + }, exportMap: { formatTitle: 'Formato do mapa', formatSubtitle: 'Escolher o formato de mapa para exportar', diff --git a/src/localization/src/translations/ru.ts b/src/localization/src/translations/ru.ts index b2a887dd27..a006a1bbae 100644 --- a/src/localization/src/translations/ru.ts +++ b/src/localization/src/translations/ru.ts @@ -489,6 +489,10 @@ export default { title: 'Облачное хранилище', subtitle: 'Авторизуйтесь, чтобы сохранить карту в вашем личном облачном хранилище' }, + providerSelect: { + disclaimer: + 'Войдите в свой аккаунт. Карты хранятся в вашем личном облаке у выбранного провайдера, а не на Kepler.gl.' + }, exportMap: { formatTitle: 'Формат карты', formatSubtitle: 'Выберите формат для экспорта карты',