Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -80,7 +81,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).
Comment thread
igorDykhta marked this conversation as resolved.
if (stored?.token && !this._isExpired(stored)) {
const scope = stored.scope || '';
if (scope && !scope.split(/\s+/).includes(DRIVE_SCOPE)) {
Expand All @@ -92,27 +95,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;
}

Expand Down Expand Up @@ -151,15 +134,16 @@ export default class GoogleDriveProvider extends Provider {
}

await this._ensureTokenClient();
// Must be called from a user gesture so the consent popup is not blocked.
// Google may grant Sign-In scopes but not Drive (granular consent) — verify and re-ask.
let tokenResponse = await this._requestAccessToken({prompt: 'select_account'});
if (!this._hasDriveScope(tokenResponse)) {
tokenResponse = await this._requestAccessToken({
prompt: 'consent',
scope: DRIVE_SCOPE
});
}
// 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.
// 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);
const tokenResponse = await this._requestAccessToken({
prompt: hadSession ? '' : 'select_account'
});
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.'
Expand All @@ -176,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;
Expand Down Expand Up @@ -377,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: () => {}
});
}
Expand All @@ -397,9 +378,23 @@ export default class GoogleDriveProvider extends Provider {
_requestAccessToken({prompt, scope} = {}) {
const run = () =>
new Promise((resolve, reject) => {
let settled = false;

const settle = (fn, value) => {
if (settled) {
return;
}
settled = true;
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;
}
// Ignore a token that arrives after the user closed the popup.
if (settled) {
return;
}
this._accessToken = response.access_token;
Expand All @@ -410,10 +405,20 @@ export default class GoogleDriveProvider extends Provider {
scope: response.scope,
user: this._readStorage()?.user
});
resolve(response);
settle(resolve, response);
};
this._tokenClient.error_callback = error => {
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) {
Expand All @@ -425,7 +430,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,
Expand All @@ -435,17 +440,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
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = () => (
<StyledDisclaimer>
<FormattedMessage id="modal.providerSelect.disclaimer" />
</StyledDisclaimer>
);
33 changes: 31 additions & 2 deletions src/components/src/modals/cloud-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -174,8 +183,28 @@ const CloudTile: React.FC<CloudTileProps> = ({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. 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);
try {
const currentUser = await provider.getUser();
if (currentUser) {
setUser(currentUser);
setIsLoading(false);
setProvider(provider);
return;
}
setUser(null);
setIsLoading(false);
} catch {
setIsLoading(false);
setProvider(provider);
return;
}
}
const nextUser = await onLogin();
if (!nextUser) {
Expand Down
12 changes: 10 additions & 2 deletions src/components/src/modals/load-storage-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -48,7 +55,7 @@ function LoadStorageMapFactory(CloudHeader: ReturnType<typeof CloudHeaderFactory
);

return (
<FlexColContainer>
<StyledLoadStorageMap>
{!currentProvider ? (
<ProviderSelect cloudProviders={cloudProviders} />
) : (
Expand All @@ -63,7 +70,8 @@ function LoadStorageMapFactory(CloudHeader: ReturnType<typeof CloudHeaderFactory
/>
</>
)}
</FlexColContainer>
<CloudStorageDisclaimer />
</StyledLoadStorageMap>
);
};

Expand Down
3 changes: 3 additions & 0 deletions src/components/src/modals/save-map-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,6 +31,7 @@ const StyledSaveMapModal = styled.div.attrs({
})`
.save-map-modal-content {
min-height: 400px;
display: flex;
flex-direction: column;
}

Expand Down Expand Up @@ -255,6 +257,7 @@ function SaveMapModalFactory() {
providerIcon={provider && provider.icon}
/>
) : null}
<CloudStorageDisclaimer />
</StyledModalContent>
</StyledSaveMapModal>
<ModalFooter cancel={onCancel} confirm={confirm} confirmButton={confirmButton} />
Expand Down
5 changes: 4 additions & 1 deletion src/components/src/modals/share-map-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'};
Expand Down Expand Up @@ -192,6 +194,7 @@ export default function ShareMapUrlModalFactory() {
)}
</StyledInnerDiv>
) : null}
<CloudStorageDisclaimer />
</StyledShareMapModal>
</ImageModalContainer>
</ThemeProvider>
Expand Down
4 changes: 4 additions & 0 deletions src/localization/src/translations/ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions src/localization/src/translations/cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,9 @@ export default {
title: '云存储',
subtitle: '登录以将地图保存到个人云存储'
},
providerSelect: {
disclaimer: '请使用您自己的账号登录。地图保存在您所选提供商的个人云存储中,而非 Kepler.gl。'
},
exportMap: {
formatTitle: '地图的格式',
formatSubtitle: '选择导出地图的格式',
Expand Down
4 changes: 4 additions & 0 deletions src/localization/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/localization/src/translations/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading