diff --git a/packages/create-common/style.css b/packages/create-common/style.css index 7baabf7..27f6ed8 100644 --- a/packages/create-common/style.css +++ b/packages/create-common/style.css @@ -220,7 +220,7 @@ body { .app-bar img { float: left; - margin-right: 0.5em; + margin: 0 1em 0 0.5em; max-height: 32px; } @@ -229,7 +229,7 @@ body { height: 100%; } -.app-bar-nav a { +.app-bar a { opacity: 0.7; color: inherit; text-decoration: none; @@ -242,16 +242,20 @@ body { 0.2s ease background; } -.app-bar-nav a:hover { +.app-bar a:hover { background: var(--app-bar-tab-background-hover); } -.app-bar-nav:not(:hover) a.active, -.app-bar-nav a:hover { +.app-bar:not(:hover) a.active, +.app-bar a:hover { opacity: 1; border-bottom: 2px solid var(--card-background); } +.app-bar .flex-space { + flex-grow: 1; +} + /****************************************************************************** * SIDEBAR */ @@ -272,6 +276,44 @@ body { flex-grow: 1; } +/****************************************************************************** + * LOGIN + */ + +.login { + background: var(--app-bar-background); + color: var(--app-bar-text-primary); + width: 100%; + height: 100%; + padding: 5em; +} + +.login .login-logo { + width: 100px; +} + +.login .login-logo, +.login .subtitle { + margin-bottom: 5em; +} + +@media screen and (min-width: 500px) { + .login .title { + font-size: 48px; + } +} + +.login button { + background: var(--text-link); + color: var(--app-bar-text-primary); + padding: 0.5em 2em; +} + +.login button:hover { + text-decoration: none; + filter: brightness(1.2); +} + /****************************************************************************** * MAP */ diff --git a/packages/create-react/.env b/packages/create-react/.env index 7827a47..29d8b3f 100644 --- a/packages/create-react/.env +++ b/packages/create-react/.env @@ -1,2 +1,7 @@ # Populated by @carto/create-common. +VITE_APP_TITLE="$title" VITE_CARTO_ACCESS_TOKEN="$accessToken" + +VITE_CARTO_AUTH_ENABLED="$authEnabled" +# VITE_CARTO_AUTH_DOMAIN="$authDomain" +VITE_CARTO_AUTH_CLIENT_ID="$authClientID" diff --git a/packages/create-react/public/carto.svg b/packages/create-react/public/carto.svg index 3cbdfc1..bfe5851 100644 --- a/packages/create-react/public/carto.svg +++ b/packages/create-react/public/carto.svg @@ -1 +1,9 @@ -Logo/Negative/Symbol + + + CARTO-logo-negative + + + + + + diff --git a/packages/create-react/src/App.tsx b/packages/create-react/src/App.tsx index cd1b4a2..f0292dc 100644 --- a/packages/create-react/src/App.tsx +++ b/packages/create-react/src/App.tsx @@ -1,11 +1,33 @@ +import { Auth0Provider } from '@auth0/auth0-react'; import { RouterProvider } from 'react-router-dom'; import { AppContext, DEFAULT_APP_CONTEXT } from './context'; import { router } from './routes'; +import { useEffect, useState } from 'react'; function App() { + const [accessToken, setAccessToken] = useState( + DEFAULT_APP_CONTEXT.accessToken, + ); + + useEffect(() => void (document.title = DEFAULT_APP_CONTEXT.title), []); + return ( - - + + + + ); } diff --git a/packages/create-react/src/components/common/AppLayout.tsx b/packages/create-react/src/components/common/AppLayout.tsx index 02f79b7..c51862d 100644 --- a/packages/create-react/src/components/common/AppLayout.tsx +++ b/packages/create-react/src/components/common/AppLayout.tsx @@ -1,6 +1,6 @@ import { ReactNode, useContext } from 'react'; import { AppContext } from '../../context'; -import { NAV_ROUTES } from '../../routes'; +import { NAV_ROUTES, RoutePath } from '../../routes'; import { NavLink, Outlet } from 'react-router-dom'; export default function AppLayout() { @@ -27,6 +27,16 @@ export default function AppLayout() { )} {context.title} + + {context.oauth.enabled && ( + + Sign out + + )}
diff --git a/packages/create-react/src/components/common/ProtectedRoute.tsx b/packages/create-react/src/components/common/ProtectedRoute.tsx index 4cf001f..12df26c 100644 --- a/packages/create-react/src/components/common/ProtectedRoute.tsx +++ b/packages/create-react/src/components/common/ProtectedRoute.tsx @@ -1,25 +1,26 @@ import { Navigate } from 'react-router-dom'; -// import { useAuth0 } from '@auth0/auth0-react'; import { RoutePath } from '../../routes'; +import { useAuth0 } from '@auth0/auth0-react'; +import { AppContext } from '../../context'; +import { useContext } from 'react'; +import { useAuth } from '../../hooks/useAuth'; export default function ProtectedRoute({ children, }: { children: JSX.Element; }) { - // TODO(impl): Auth - // const { isAuthenticated, isLoading } = useAuth0(); + useAuth(); + const { isAuthenticated, isLoading } = useAuth0(); + const { oauth, accessToken } = useContext(AppContext); - // if (!initialState.oauth) { - // return children; - // } - - const authenticated = true; //notAuthenticated = !isLoading && !isAuthenticated && !accessToken; + if (!oauth.enabled) { + return children; + } - if (!authenticated) { + if (!isLoading && !isAuthenticated && !accessToken) { return ; } - return children; - // return !!accessToken ? children : null; + return accessToken ? children : null; } diff --git a/packages/create-react/src/components/views/Default.tsx b/packages/create-react/src/components/views/Default.tsx index b9e8f14..39a1b35 100644 --- a/packages/create-react/src/components/views/Default.tsx +++ b/packages/create-react/src/components/views/Default.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useContext, useEffect, useMemo, useState } from 'react'; import { Map } from 'react-map-gl/maplibre'; import DeckGL from '@deck.gl/react'; @@ -11,6 +11,7 @@ import { Layers } from '../common/Layers'; import { FormulaWidget } from '../widgets/FormulaWidget'; import { CategoryWidget } from '../widgets/CategoryWidget'; import { useDebouncedState } from '../../hooks'; +import { AppContext } from '../../context'; const MAP_VIEW = new MapView({ repeat: true }); const MAP_STYLE = @@ -31,6 +32,7 @@ const RADIO_COLORS: AccessorFunction = colorCategories({ }); export default function Default() { + const { accessToken, apiBaseUrl } = useContext(AppContext); const [filters, setFilters] = useState({} as Record); const [attributionHTML, setAttributionHTML] = useState(''); const [viewState, setViewState] = useDebouncedState(INITIAL_VIEW_STATE, 200); @@ -41,13 +43,14 @@ export default function Default() { const data = useMemo(() => { return vectorQuerySource({ - accessToken: import.meta.env.VITE_CARTO_ACCESS_TOKEN, + accessToken, + apiBaseUrl, connectionName: 'carto_dw', sqlQuery: 'SELECT * FROM `carto-demo-data.demo_tables.cell_towers_worldwide`', filters, }); - }, [filters]); + }, [accessToken, apiBaseUrl, filters]); /**************************************************************************** * Layers (https://deck.gl/docs/api-reference/carto/overview#carto-layers) diff --git a/packages/create-react/src/components/views/Login.tsx b/packages/create-react/src/components/views/Login.tsx index 042bbc8..f842fae 100644 --- a/packages/create-react/src/components/views/Login.tsx +++ b/packages/create-react/src/components/views/Login.tsx @@ -1,3 +1,19 @@ +import { useAuth0 } from '@auth0/auth0-react'; +import { useContext } from 'react'; +import { AppContext } from '../../context'; + export default function Login() { - return

Login

; + const { title, logo } = useContext(AppContext); + const { loginWithRedirect } = useAuth0(); + return ( +
+ {logo && {logo.alt}} +

{title}

+

Discover the power of developing with React

+ +

Use your CARTO credentials

+
+ ); } diff --git a/packages/create-react/src/components/views/Logout.tsx b/packages/create-react/src/components/views/Logout.tsx new file mode 100644 index 0000000..b872c70 --- /dev/null +++ b/packages/create-react/src/components/views/Logout.tsx @@ -0,0 +1,9 @@ +import { useAuth0 } from '@auth0/auth0-react'; + +export default function Logout() { + const { logout } = useAuth0(); + + logout(); + + return

Logging out…

; +} diff --git a/packages/create-react/src/components/views/Secondary.tsx b/packages/create-react/src/components/views/Secondary.tsx index 137f6ca..4739ca3 100644 --- a/packages/create-react/src/components/views/Secondary.tsx +++ b/packages/create-react/src/components/views/Secondary.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useContext, useEffect, useMemo, useState } from 'react'; import { Map } from 'react-map-gl/maplibre'; import DeckGL from '@deck.gl/react'; @@ -8,6 +8,7 @@ import { h3TableSource } from '@carto/api-client'; import { Legend } from '../common/Legend'; import { Layers } from '../common/Layers'; import { Card } from '../common/Card'; +import { AppContext } from '../../context'; const MAP_VIEW = new MapView({ repeat: true }); const MAP_STYLE = @@ -28,6 +29,7 @@ const POP_COLORS: AccessorFunction = colorContinuous({ }); export default function Default() { + const { accessToken, apiBaseUrl } = useContext(AppContext); const [attributionHTML, setAttributionHTML] = useState(''); const [viewState, setViewState] = useState(INITIAL_VIEW_STATE); @@ -37,14 +39,15 @@ export default function Default() { const data = useMemo(() => { return h3TableSource({ - accessToken: import.meta.env.VITE_CARTO_ACCESS_TOKEN, + accessToken, + apiBaseUrl, connectionName: 'carto_dw', tableName: 'carto-demo-data.demo_tables.derived_spatialfeatures_usa_h3res8_v1_yearly_v2', spatialDataColumn: 'h3', aggregationExp: 'SUM(population) as population_sum', }); - }, []); + }, [accessToken, apiBaseUrl]); /**************************************************************************** * Layers (https://deck.gl/docs/api-reference/carto/overview#carto-layers) diff --git a/packages/create-react/src/context.ts b/packages/create-react/src/context.ts index 561335f..2dffc1c 100644 --- a/packages/create-react/src/context.ts +++ b/packages/create-react/src/context.ts @@ -7,26 +7,50 @@ export interface AppContextProps { src: string; alt: string; }; - credentials: { - accessToken: string; - apiVersion?: string; - apiBaseUrl?: string; - }; + accessToken: string; + setAccessToken: (token: string) => void; + apiVersion?: string; + apiBaseUrl?: string; googleApiKey?: string; googleMapId?: string; accountsUrl?: string; + oauth: { + enabled: boolean; + domain: string; + clientId?: string; + organizationId?: string; + namespace: string; + scopes: string[]; + audience: string; + authorizeEndPoint?: string; + }; } export const DEFAULT_APP_CONTEXT = { - title: '$title', + title: import.meta.env.VITE_APP_TITLE, logo: { src: cartoLogo, alt: 'CARTO logo', }, - credentials: { - accessToken: import.meta.env.VITE_CARTO_ACCESS_TOKEN, - }, + accessToken: import.meta.env.VITE_CARTO_ACCESS_TOKEN, + setAccessToken: () => {}, + apiBaseUrl: 'https://gcp-us-east1.api.carto.com', accountsUrl: 'http://app.carto.com/', + oauth: { + enabled: import.meta.env.VITE_CARTO_AUTH_ENABLED === 'true', + domain: 'auth.carto.com', + clientId: import.meta.env.VITE_CARTO_AUTH_CLIENT_ID, + organizationId: import.meta.env.VITE_CARTO_AUTH_ORGANIZATION_ID, // Required for SSO. + namespace: 'http://app.carto.com/', + scopes: [ + 'read:current_user', + 'read:connections', + 'read:maps', + 'read:account', + ], + audience: 'carto-cloud-native-api', + authorizeEndPoint: 'https://carto.com/oauth2/authorize', // Only valid if keeping https://localhost:3000/oauthCallback + }, }; export const AppContext = createContext(DEFAULT_APP_CONTEXT); diff --git a/packages/create-react/src/hooks/useAuth.ts b/packages/create-react/src/hooks/useAuth.ts new file mode 100644 index 0000000..6c9eefb --- /dev/null +++ b/packages/create-react/src/hooks/useAuth.ts @@ -0,0 +1,54 @@ +import { useCallback, useContext, useEffect, useMemo } from 'react'; +import { useAuth0 } from '@auth0/auth0-react'; +import { useSearchParams } from 'react-router-dom'; +import { AppContext } from '../context'; + +const FORCE_LOGIN_PARAM = 'forceLogin'; + +export function useAuth() { + const { setAccessToken, accountsUrl, oauth } = useContext(AppContext); + const { isAuthenticated, getAccessTokenSilently, user, loginWithRedirect } = + useAuth0(); + const [searchParams] = useSearchParams(); + + const organizationId = oauth.organizationId || ''; + const namespace = oauth.namespace; + const hasForceLogin = searchParams.has(FORCE_LOGIN_PARAM); + + const getAccessToken = useCallback(async () => { + setAccessToken(await getAccessTokenSilently()); + }, [setAccessToken, getAccessTokenSilently]); + + const userMetadata = useMemo(() => { + if (!user) return; + return user[`${namespace}user_metadata`]; + }, [user, namespace]); + + const redirectAccountUri = useMemo(() => { + return `${accountsUrl}${organizationId ? `sso/${organizationId}` : ''}`; + }, [accountsUrl, organizationId]); + + useEffect(() => { + if (hasForceLogin) { + // if FORCE_LOGIN_PARAM is set a relogin is required to refresh userMetadata + loginWithRedirect(); + } else if (isAuthenticated && userMetadata) { + getAccessToken(); + } else if (isAuthenticated) { + // No organizations associated with the user, we need to redirect to app.carto.com to complete the signup process + const searchParams = new URLSearchParams({ + redirectUri: `${window.location.origin}?${FORCE_LOGIN_PARAM}=true`, + }); + window.location.href = `${redirectAccountUri}?${searchParams}`; + } + }, [ + hasForceLogin, + getAccessToken, + isAuthenticated, + loginWithRedirect, + redirectAccountUri, + userMetadata, + ]); + + return; +} diff --git a/packages/create-react/src/main.tsx b/packages/create-react/src/main.tsx index 571db90..f580d6f 100644 --- a/packages/create-react/src/main.tsx +++ b/packages/create-react/src/main.tsx @@ -3,7 +3,6 @@ import ReactDOM from 'react-dom/client'; import App from './App.tsx'; import '@carto/create-common/style.css'; -// TODO: Auth provider. ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/packages/create-react/src/routes.tsx b/packages/create-react/src/routes.tsx index 0282482..a804748 100644 --- a/packages/create-react/src/routes.tsx +++ b/packages/create-react/src/routes.tsx @@ -11,8 +11,9 @@ const Secondary = lazy(() => import('./components/views/Secondary')); const NotFound = lazy(() => import('./components/views/NotFound')); // eslint-disable-next-line react-refresh/only-export-components const Login = lazy(() => import('./components/views/Login')); +// eslint-disable-next-line react-refresh/only-export-components +const Logout = lazy(() => import('./components/views/Logout')); -// TODO: /logout ? export const RoutePath: Record = { DEFAULT: '/', US_POPULATION: '/usa-population', @@ -41,6 +42,7 @@ export const routes: RouteObject[] = [ ], }, { path: RoutePath.LOGIN, element: }, + { path: RoutePath.LOGOUT, element: }, { path: '*', element: }, ]; diff --git a/packages/create-vue/public/carto.svg b/packages/create-vue/public/carto.svg index 3cbdfc1..bfe5851 100644 --- a/packages/create-vue/public/carto.svg +++ b/packages/create-vue/public/carto.svg @@ -1 +1,9 @@ -Logo/Negative/Symbol + + + CARTO-logo-negative + + + + + +