From b657139e92d9694242a8f8da9dfbc77c37b52637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Zbytovsk=C3=BD?= Date: Thu, 18 Apr 2024 10:38:00 +0200 Subject: [PATCH] App: server side login + tweaks (#316) --- pages/api/token-login.ts | 15 ++++++ public/oauth-token.html | 4 +- src/components/App/App.tsx | 10 ++-- src/components/FeaturePanel/ObjectsAround.tsx | 3 +- src/components/Map/TopMenu/HamburgerMenu.tsx | 21 +++++---- src/components/Map/TopMenu/LoginIcon.tsx | 1 + src/components/utils/FeatureContext.tsx | 6 +-- src/components/utils/OsmAuthContext.tsx | 14 ++++-- src/services/fetch.ts | 16 +------ src/services/helpers.ts | 15 ++++++ src/services/osmApi.ts | 11 ++++- src/services/osmApiAuth.ts | 26 ++++++---- src/services/osmApiAuthServer.ts | 47 +++++++++++++++++++ 13 files changed, 137 insertions(+), 52 deletions(-) create mode 100644 pages/api/token-login.ts create mode 100644 src/services/osmApiAuthServer.ts diff --git a/pages/api/token-login.ts b/pages/api/token-login.ts new file mode 100644 index 00000000..ba97a0c5 --- /dev/null +++ b/pages/api/token-login.ts @@ -0,0 +1,15 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; +import { serverFetchOsmUser } from '../../src/services/osmApiAuthServer'; + +// TODO upgrade Nextjs and use export async function POST(request: NextRequest) { +export default async (req: NextApiRequest, res: NextApiResponse) => { + try { + const { osmAccessToken } = req.cookies; + const user = await serverFetchOsmUser({ osmAccessToken }); + + res.status(200).json({ user }); + } catch (err) { + console.error(err); // eslint-disable-line no-console + res.status(err.code ?? 400).send(String(err)); + } +}; diff --git a/public/oauth-token.html b/public/oauth-token.html index d21113ce..b181e4b1 100644 --- a/public/oauth-token.html +++ b/public/oauth-token.html @@ -9,12 +9,12 @@

OsmAPP auth popup

[en] If this popup doesn't close automatically, you may close it and - proceed back to osmapp.org. + proceed back to the app.

[cs] Pokud se toto okno nezavře automaticky, můžete ho zavřít a - pokračovat na osmapp.org. + pokračovat do aplikace.

diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index 60382754..380f34d7 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -94,12 +94,12 @@ const IndexWithProviders = () => { ); }; -const App = ({ featureFromRouter, initialMapView, hpCookie }) => { +const App = ({ featureFromRouter, initialMapView, cookies }) => { const mapView = getMapViewFromHash() || initialMapView; return ( - + - + @@ -113,10 +113,10 @@ const App = ({ featureFromRouter, initialMapView, hpCookie }) => { App.getInitialProps = async (ctx) => { await setIntlForSSR(ctx); // needed for lang urls like /es/node/123 - const { hideHomepage: hpCookie } = nextCookies(ctx); + const cookies = nextCookies(ctx); const featureFromRouter = await getInititalFeature(ctx); const initialMapView = await getInitialMapView(ctx); - return { featureFromRouter, initialMapView, hpCookie }; + return { featureFromRouter, initialMapView, cookies }; }; export default App; diff --git a/src/components/FeaturePanel/ObjectsAround.tsx b/src/components/FeaturePanel/ObjectsAround.tsx index 3e2ce21d..1eb5a96e 100644 --- a/src/components/FeaturePanel/ObjectsAround.tsx +++ b/src/components/FeaturePanel/ObjectsAround.tsx @@ -4,10 +4,9 @@ import Router from 'next/router'; import { fetchAroundFeature } from '../../services/osmApi'; import { useFeatureContext } from '../utils/FeatureContext'; import { Feature } from '../../services/types'; -import { getOsmappLink, getUrlOsmId } from '../../services/helpers'; +import { FetchError, getOsmappLink, getUrlOsmId } from '../../services/helpers'; import Maki from '../utils/Maki'; import { t } from '../../services/intl'; -import { FetchError } from '../../services/fetch'; import { trimText, useMobileMode } from '../helpers'; import { getLabel } from '../../helpers/featureLabel'; import { useUserThemeContext } from '../../helpers/theme'; diff --git a/src/components/Map/TopMenu/HamburgerMenu.tsx b/src/components/Map/TopMenu/HamburgerMenu.tsx index 31c24c8a..f07a4b27 100644 --- a/src/components/Map/TopMenu/HamburgerMenu.tsx +++ b/src/components/Map/TopMenu/HamburgerMenu.tsx @@ -156,10 +156,20 @@ const ThemeSelection = () => { const UserLogin = forwardRef(({ closeMenu }, ref) => { const { osmUser, handleLogin, handleLogout } = useOsmAuthContext(); + const login = () => { + closeMenu(); + handleLogin(); + }; + const logout = () => { + closeMenu(); + setTimeout(() => { + handleLogout(); + }, 100); + }; if (!osmUser) { return ( - + {t('user.login')} @@ -178,18 +188,11 @@ const UserLogin = forwardRef(({ closeMenu }, ref) => { {osmUser} - - {t('user.logout')} + {t('user.logout')} ); }); -// TODO maybe -// -// -// -// - // TODO custom Item components are not keyboard accesible // seems like a bug in material-ui // https://github.com/mui-org/material-ui/issues/22912 diff --git a/src/components/Map/TopMenu/LoginIcon.tsx b/src/components/Map/TopMenu/LoginIcon.tsx index 7cc03126..d3e88b06 100644 --- a/src/components/Map/TopMenu/LoginIcon.tsx +++ b/src/components/Map/TopMenu/LoginIcon.tsx @@ -8,6 +8,7 @@ const StyledUserImg = styled.img` width: 24px; height: 24px; border-radius: 50%; + background-color: white; `; export const LoginIcon = ({ onClick }) => { const { osmUser, userImage } = useOsmAuthContext(); diff --git a/src/components/utils/FeatureContext.tsx b/src/components/utils/FeatureContext.tsx index 57c9e842..6d388ad8 100644 --- a/src/components/utils/FeatureContext.tsx +++ b/src/components/utils/FeatureContext.tsx @@ -29,13 +29,13 @@ export const FeatureContext = createContext(undefined); interface Props { featureFromRouter: Feature | null; children: ReactNode; - hpCookie: string; + cookies: Record; } export const FeatureProvider = ({ children, featureFromRouter, - hpCookie, + cookies, }: Props) => { const [preview, setPreview] = useState(null); const [feature, setFeature] = useState(featureFromRouter); @@ -49,7 +49,7 @@ export const FeatureProvider = ({ }, [featureFromRouter]); const [homepageShown, showHomepage, hideHomepage] = useBoolState( - feature == null && hpCookie !== 'yes', + feature == null && cookies.hideHomepage !== 'yes', ); const persistShowHomepage = () => { setFeature(null); diff --git a/src/components/utils/OsmAuthContext.tsx b/src/components/utils/OsmAuthContext.tsx index 945f6a3d..3c2dc338 100644 --- a/src/components/utils/OsmAuthContext.tsx +++ b/src/components/utils/OsmAuthContext.tsx @@ -1,7 +1,6 @@ import React, { createContext, useContext, useState } from 'react'; import { - fetchOsmUser, - getOsmUser, + loginAndfetchOsmUser, osmLogout, OsmUser, } from '../../services/osmApiAuth'; @@ -15,11 +14,16 @@ interface OsmAuthType { handleLogout: () => void; } +const useOsmUserState = (cookies) => { + const initialState = cookies.osmUserForSSR; + return useState(initialState); +}; + export const OsmAuthContext = createContext(undefined); -export const OsmAuthProvider = ({ children }) => { +export const OsmAuthProvider = ({ children, cookies }) => { const mapStateContext = useMapStateContext(); - const [osmUser, setOsmUser] = useState(getOsmUser()); + const [osmUser, setOsmUser] = useOsmUserState(cookies); const successfulLogin = (user: OsmUser) => { setOsmUser(user); @@ -29,7 +33,7 @@ export const OsmAuthProvider = ({ children }) => { }); }; - const handleLogin = () => fetchOsmUser().then(successfulLogin); + const handleLogin = () => loginAndfetchOsmUser().then(successfulLogin); const handleLogout = () => osmLogout().then(() => setOsmUser(undefined)); const value = { diff --git a/src/services/fetch.ts b/src/services/fetch.ts index 62a95b59..cd1014d6 100644 --- a/src/services/fetch.ts +++ b/src/services/fetch.ts @@ -1,21 +1,7 @@ import fetch from 'isomorphic-unfetch'; import { getCache, getKey, writeCacheSafe } from './fetchCache'; import { isBrowser } from '../components/helpers'; - -export class FetchError extends Error { - constructor( - public message: string = '', - public code: string, - public data: string, - ) { - super(); - } - - toString() { - const suffix = this.data && ` Data: ${this.data.substring(0, 1000)}`; - return `Fetch: ${this.message}${suffix}`; - } -} +import { FetchError } from './helpers'; // TODO cancel request in map.on('click', ...) const abortableQueues: Record = {}; diff --git a/src/services/helpers.ts b/src/services/helpers.ts index 399518e0..47a25b8f 100644 --- a/src/services/helpers.ts +++ b/src/services/helpers.ts @@ -154,3 +154,18 @@ export const doShortenerRedirect = (ctx) => { return false; }; + +export class FetchError extends Error { + constructor( + public message: string = '', + public code: string, + public data: string, + ) { + super(); + } + + toString() { + const suffix = this.data && ` Data: ${this.data.substring(0, 1000)}`; + return `Fetch: ${this.message}${suffix}`; + } +} diff --git a/src/services/osmApi.ts b/src/services/osmApi.ts index ef30ee0e..9da8bac2 100644 --- a/src/services/osmApi.ts +++ b/src/services/osmApi.ts @@ -1,5 +1,12 @@ -import { getApiId, getShortId, getUrlOsmId, OsmApiId, prod } from './helpers'; -import { FetchError, fetchJson } from './fetch'; +import { + FetchError, + getApiId, + getShortId, + getUrlOsmId, + OsmApiId, + prod, +} from './helpers'; +import { fetchJson } from './fetch'; import { Feature, Position } from './types'; import { removeFetchCache } from './fetchCache'; import { overpassAroundToSkeletons } from './overpassAroundToSkeletons'; diff --git a/src/services/osmApiAuth.ts b/src/services/osmApiAuth.ts index a4f74822..ba5f2ea3 100644 --- a/src/services/osmApiAuth.ts +++ b/src/services/osmApiAuth.ts @@ -1,3 +1,4 @@ +import Cookies from 'js-cookie'; import escape from 'lodash/escape'; import getConfig from 'next/config'; import { osmAuth } from 'osm-auth'; @@ -57,25 +58,32 @@ export const fetchOsmUser = async (): Promise => { path: '/api/0.6/user/details.json', }); const details = JSON.parse(response).user; - const user = { + return { name: details.display_name, imageUrl: details.img?.href ?? `https://www.gravatar.com/avatar/${details.id}?s=24&d=robohash`, }; - - window.localStorage.setItem('osm_user', JSON.stringify(user)); - return user; }; -export const getOsmUser = (): OsmUser | undefined => - auth.authenticated() - ? JSON.parse(window.localStorage.getItem('osm_user')) - : undefined; +export const loginAndfetchOsmUser = async (): Promise => { + const osmUser = await fetchOsmUser(); + + const { url } = auth.options(); + const osmAccessToken = localStorage.getItem(`${url}oauth2_access_token`); + const osmUserForSSR = JSON.stringify(osmUser); + Cookies.set('osmAccessToken', osmAccessToken, { path: '/', expires: 365 }); + Cookies.set('osmUserForSSR', osmUserForSSR, { path: '/', expires: 365 }); + + await fetch('/api/token-login'); + + return osmUser; +}; export const osmLogout = async () => { auth.logout(); - window.localStorage.removeItem('osm_user'); + Cookies.remove('osmAccessToken', { path: '/' }); + Cookies.remove('osmUserForSSR', { path: '/' }); }; const getChangesetXml = ({ changesetComment, feature }) => { diff --git a/src/services/osmApiAuthServer.ts b/src/services/osmApiAuthServer.ts new file mode 100644 index 00000000..6454e30c --- /dev/null +++ b/src/services/osmApiAuthServer.ts @@ -0,0 +1,47 @@ +import fetch from 'isomorphic-unfetch'; +import { FetchError } from './helpers'; + +interface OsmAuthFetchOpts extends RequestInit { + osmAccessToken: string; +} + +const osmAuthFetch = async ( + endpoint: string, + options: OsmAuthFetchOpts, +): Promise => { + const { osmAccessToken, ...restOptions } = options; + if (!osmAccessToken) throw new Error('No access token'); + + const url = `https://api.openstreetmap.org${endpoint}`; + const headers = { + 'User-Agent': 'osmapp (SSR; https://osmapp.org/)', + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${osmAccessToken}`, + }; + + const response = await fetch(url, { + ...restOptions, + headers, + }); + + if (!response.ok || response.status < 200 || response.status >= 300) { + const data = await response.text(); + throw new FetchError( + `${response.status} ${response.statusText}`, + `${response.status}`, + data, + ); + } + + return response.json(); +}; + +export const serverFetchOsmUser = async ( + options: OsmAuthFetchOpts, +): Promise<{ id: number; username: string }> => { + const { user } = await osmAuthFetch('/api/0.6/user/details.json', options); + return { + id: user.id, + username: user.display_name, + }; +};