From 6f18583cacfeba88c467dd7efde32b4dadf3841b Mon Sep 17 00:00:00 2001 From: Manjita Pandey <97273021+manjitapandey@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:56:48 +0545 Subject: [PATCH 01/15] OData Connection for Submission Analysis Signed-off-by: Manjita Pandey <97273021+manjitapandey@users.noreply.github.com> --- .../OData Connection for Submission Analysis | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/manuals/OData Connection for Submission Analysis diff --git a/docs/manuals/OData Connection for Submission Analysis b/docs/manuals/OData Connection for Submission Analysis new file mode 100644 index 0000000000..b5cc0867d4 --- /dev/null +++ b/docs/manuals/OData Connection for Submission Analysis @@ -0,0 +1,27 @@ +If you want to visualise the submissions and create custom charts +and diagrams using FMTM submissions, then you can follow the steps +below and load the submissions on any data analysis platform using OData. +OData endpoints are a standardised way to ingest +this data into other tools: PowerBI, Tableau, Redash, Grafana + +Why PowerBI? You can use other tools too like Tableau, Redash, Grafana or even +Excel. However, PowerBI is free to use, very powerful, and user friendly though its Proprietary Microsoft. + +The steps shows how to use powerBI and create custom visualisations. +ODK already has good docs on this which you can refer to. +https://docs.getodk.org/tutorial-mapping-households/ + +Step 1: Start a new project, add a new data source 'OData Feed' +https://private-user-images.githubusercontent.com/78538841/366426499-376de20e-61e5-452e-ab76-669bf1940c72.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MzM5MDgwMTUsIm5iZiI6MTczMzkwNzcxNSwicGF0aCI6Ii83ODUzODg0MS8zNjY0MjY0OTktMzc2ZGUyMGUtNjFlNS00NTJlLWFiNzYtNjY5YmYxOTQwYzcyLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDEyMTElMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQxMjExVDA5MDE1NVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPThiMWYzOTBiMDQyNGEzNDI1ZjBjZDg1Mjg2ZmE0NGU4Yzg1MGQyZWIyN2Q5YjQ4MGJmZmRlYzYzMWI3OWMxZWQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.riSVz4Y2tiFXqjkaSWsmsOvLjhx8M_5YiA-LSQ7FBys +Step 2: Use the OData URLs shown in the ODK docs: +a. Submission data: /v1/projects/{projectId}/forms/{xmlFormId}.svc +e.g. https://odk.hotosm.org/v1/projects/86/forms/df9135c8-84b1-4753-b348-e8963a8b4088.svc +a. Entity data: /v1/projects/{projectId}/datasets/{name}.svc +e.g. https://odk.hotosm.org/v1/projects/86/datasets/features.svc + +Step 3: Enter your credentials using Basic Auth + +Step 4: Preview the data + +Step 5: Transform data as needed, load into charts, graphs, etc, to create the dashboard you need. + From 1b0ed1bdbcd87a597a60aa8f425b73b463059ecb Mon Sep 17 00:00:00 2001 From: Sam <78538841+spwoodcock@users.noreply.github.com> Date: Thu, 12 Dec 2024 02:49:05 +0545 Subject: [PATCH 02/15] fix: finalise auth setup between frontends (#1981) * refactor(frontend): remove DebugConsole, conflicting with CheckLoginState * fix: rework authentication logic between two frontends --- src/backend/app/auth/auth_deps.py | 84 ++++++++++-- src/backend/app/auth/auth_routes.py | 123 ++++++------------ src/backend/app/auth/auth_schemas.py | 39 ++++-- src/backend/app/auth/providers/osm.py | 15 ++- src/frontend/src/App.tsx | 59 ++++----- src/frontend/src/components/LoginPopup.tsx | 2 +- src/frontend/src/store/slices/LoginSlice.ts | 1 - src/frontend/src/utilfunctions/login.ts | 34 +++-- src/frontend/src/utilities/CustomDrawer.tsx | 14 +- src/frontend/src/utilities/DebugConsole.tsx | 51 -------- src/frontend/src/utilities/PrimaryAppBar.tsx | 8 +- src/frontend/src/views/OsmAuth.tsx | 63 +++++---- .../src/views/PlaywrightTempLogin.tsx | 36 ++--- src/frontend/src/views/ProjectDetailsV2.tsx | 18 --- src/mapper/src/lib/components/header.svelte | 14 +- src/mapper/src/lib/components/login.svelte | 2 +- src/mapper/src/lib/utils/login.ts | 34 +++++ src/mapper/src/routes/[projectId]/+page.ts | 22 ++-- src/mapper/src/store/login.svelte.ts | 25 +--- 19 files changed, 326 insertions(+), 318 deletions(-) delete mode 100644 src/frontend/src/utilities/DebugConsole.tsx diff --git a/src/backend/app/auth/auth_deps.py b/src/backend/app/auth/auth_deps.py index fcd24db6f9..2044b851c8 100644 --- a/src/backend/app/auth/auth_deps.py +++ b/src/backend/app/auth/auth_deps.py @@ -18,11 +18,12 @@ """Auth dependencies, for restricted routes and cookie handling.""" -import time +from time import time from typing import Optional import jwt from fastapi import Header, HTTPException, Request, Response +from fastapi.responses import JSONResponse from loguru import logger as log from app.auth.auth_schemas import AuthUser @@ -67,14 +68,16 @@ def set_cookie( def set_cookies( + response: Response, access_token: str, refresh_token: str, cookie_name: str = settings.cookie_name, refresh_cookie_name: str = f"{settings.cookie_name}_refresh", -) -> Response: +) -> JSONResponse: """Set cookies for the access and refresh tokens. Args: + response (str): The response to attach the cookies to. access_token (str): The access token to be stored in the cookie. refresh_token (str): The refresh token to be stored in the cookie. cookie_name (str, optional): The name of the cookie to store the access token. @@ -82,13 +85,8 @@ def set_cookies( refresh token. Returns: - Response: A response with attached cookies (set-cookie headers). + JSONResponse: A response with attached cookies (set-cookie headers). """ - # NOTE if needed we can return the token in the JSON response, but we don't for now - # response = JSONResponse(status_code=HTTPStatus.OK, - # content={"token": access_token}) - response = Response(status_code=HTTPStatus.OK) - secure = not settings.DEBUG domain = settings.FMTM_DOMAIN @@ -123,7 +121,7 @@ def create_jwt_tokens(input_data: dict) -> tuple[str, str]: """ access_token_data = input_data.copy() # Set refresh token expiry to 7 days - refresh_token_data = {**input_data, "exp": int(time.time()) + 86400 * 7} + refresh_token_data = {**input_data, "exp": int(time()) + 86400 * 7} encryption_key = settings.ENCRYPTION_KEY.get_secret_value() algorithm = settings.JWT_ENCRYPTION_ALGORITHM @@ -140,7 +138,7 @@ def refresh_jwt_token( expiry_seconds: int = 86400, ) -> str: """Generate a new JTW token with expiry.""" - payload["exp"] = int(time.time()) + expiry_seconds + payload["exp"] = int(time()) + expiry_seconds return jwt.encode( payload, settings.ENCRYPTION_KEY.get_secret_value(), @@ -188,6 +186,58 @@ def verify_jwt_token(token: str, ignore_expiry: bool = False) -> dict: ) from e +async def refresh_cookies( + request: Request, + current_user: AuthUser, + cookie_name: str, + refresh_cookie_name: str, +): + """Reusable function to renew the expiry on cookies. + + Used by both management and mapper refresh endpoints. + """ + if settings.DEBUG: + return JSONResponse( + status_code=HTTPStatus.OK, + content={**current_user.model_dump()}, + ) + + access_token = get_cookie_value(request, cookie_name) + if not access_token: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="No access token provided", + ) + + refresh_token = get_cookie_value(request, refresh_cookie_name) + if not refresh_token: + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, + detail="No refresh token provided", + ) + + # Decode JWT and get data from both cookies, + # checking refresh expiry is valid first + refresh_token_data = verify_jwt_token(refresh_token) + access_token_data = verify_jwt_token(access_token, ignore_expiry=True) + + try: + # Refresh token + refresh token + new_access_token = refresh_jwt_token(access_token_data) + new_refresh_token = refresh_jwt_token(refresh_token_data) + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to refresh tokens: {e}", + ) from e + + # NOTE Append the user data to the JSONResponse so we can display in the + # frontend header. For the mapper frontend this is enough, but for the + # management frontend we instead use the return from /auth/me + response = JSONResponse(status_code=HTTPStatus.OK, content=access_token_data) + return set_cookies(response, new_access_token, new_refresh_token) + + ### Endpoint Dependencies ### @@ -219,7 +269,19 @@ async def mapper_login_required( settings.cookie_name, # OSM cookie f"{settings.cookie_name}_temp", # Temp cookie ) - return await _authenticate_user(extracted_token) + + # Verify login and continue + if extracted_token: + return await _authenticate_user(extracted_token) + + # Else user has no token, so we provide login data automatically + username = "svcfmtm" + temp_user = { + "sub": "fmtm|20386219", + "username": username, + "role": UserRole.MAPPER, + } + return AuthUser(**temp_user) async def _authenticate_user(access_token: Optional[str]) -> AuthUser: diff --git a/src/backend/app/auth/auth_routes.py b/src/backend/app/auth/auth_routes.py index 7a8f9a4ba3..247c3ced2a 100644 --- a/src/backend/app/auth/auth_routes.py +++ b/src/backend/app/auth/auth_routes.py @@ -19,7 +19,7 @@ """Auth routes, to login, logout, and get user details.""" from time import time -from typing import Annotated +from typing import Annotated, Optional from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse @@ -29,12 +29,10 @@ from app.auth.auth_deps import ( create_jwt_tokens, - get_cookie_value, login_required, mapper_login_required, - refresh_jwt_token, + refresh_cookies, set_cookies, - verify_jwt_token, ) from app.auth.auth_schemas import AuthUser, FMTMUser from app.auth.providers.osm import handle_osm_callback, init_osm_auth @@ -80,6 +78,7 @@ async def callback( Also returns a cookie containing the access token for persistence in frontend apps. """ try: + # This includes the main cookie, refresh cookie, osm token cookie response_plus_cookies = await handle_osm_callback(request, osm_auth) return response_plus_cookies except Exception as e: @@ -164,7 +163,7 @@ async def get_or_create_user( { "user_id": user_data.id, "username": user_data.username, - "profile_img": user_data.picture or "", + "profile_img": user_data.profile_img or "", "role": UserRole(user_data.role).name, }, ) @@ -211,64 +210,7 @@ async def my_data( return await get_or_create_user(db, current_user) -async def refresh_fmtm_cookies(request: Request, current_user: AuthUser): - """Reusable function to renew the expiry on the FMTM and expiry tokens. - - Used by both management and mapper refresh endpoints. - """ - if settings.DEBUG: - return JSONResponse( - status_code=HTTPStatus.OK, - content={**current_user.model_dump()}, - ) - - try: - access_token = get_cookie_value( - request, - settings.cookie_name, # OSM cookie - ) - if not access_token: - raise HTTPException( - status_code=HTTPStatus.UNAUTHORIZED, - detail="No access token provided", - ) - - refresh_token = get_cookie_value( - request, - f"{settings.cookie_name}_refresh", # OSM refresh cookie - ) - if not refresh_token: - raise HTTPException( - status_code=HTTPStatus.UNAUTHORIZED, - detail="No refresh token provided", - ) - - # Decode JWT and get data from both cookies - access_token_data = verify_jwt_token(access_token, ignore_expiry=True) - refresh_token_data = verify_jwt_token(refresh_token) - - # Refresh token + refresh token - new_access_token = refresh_jwt_token(access_token_data) - new_refresh_token = refresh_jwt_token(refresh_token_data) - - response = set_cookies(new_access_token, new_refresh_token) - # Append the user data to the JSONResponse - # We use this in the frontend to determine if the token user matches the - # currently logged in user. If no, we clear the frontend auth state. - return JSONResponse( - status=response.status, - headers=response.headers, - content=access_token_data, - ) - - except Exception as e: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Failed to refresh the access token: {e}", - ) from e - - -@router.get("/refresh/management", response_model=AuthUser) +@router.get("/refresh/management", response_model=FMTMUser) async def refresh_management_cookies( request: Request, current_user: Annotated[AuthUser, Depends(login_required)], @@ -278,16 +220,20 @@ async def refresh_management_cookies( This endpoint is specific to the management desktop frontend. Any temp auth cookies will be ignored and removed. OSM login is required. + + NOTE this endpoint has no db calls and returns in ~2ms. """ - response = await refresh_fmtm_cookies(request, current_user) + response = await refresh_cookies( + request, + current_user, + settings.cookie_name, + f"{settings.cookie_name}_refresh", + ) # Invalidate any temp cookies from mapper frontend - fmtm_cookie_name = settings.cookie_name - temp_cookie_name = f"{fmtm_cookie_name}_temp" - temp_refresh_cookie_name = f"{fmtm_cookie_name}_temp_refresh" for cookie_name in [ - temp_cookie_name, - temp_refresh_cookie_name, + f"{settings.cookie_name}_temp", + f"{settings.cookie_name}_temp_refresh", ]: log.debug(f"Resetting cookie in response named '{cookie_name}'") response.set_cookie( @@ -305,7 +251,7 @@ async def refresh_management_cookies( return response -@router.get("/refresh/mapper", response_model=AuthUser) +@router.get("/refresh/mapper", response_model=Optional[FMTMUser]) async def refresh_mapper_token( request: Request, current_user: Annotated[AuthUser, Depends(mapper_login_required)], @@ -315,34 +261,41 @@ async def refresh_mapper_token( This endpoint is specific to the mapper mobile frontend. By default the user will be logged in with a temporary auth cookie. OSM auth is optional, if the user wishes to be attributed for contributions. + + NOTE this endpoint has no db calls and returns in ~2ms. """ try: - response = await refresh_fmtm_cookies(request, current_user) + # If standard login cookie is passed, use that + response = await refresh_cookies( + request, + current_user, + settings.cookie_name, + f"{settings.cookie_name}_refresh", + ) return response except HTTPException: # NOTE we allow for token verification to fail for the main cookie # and fallback to to generate a temp auth cookie pass - username = "svcfmtm" - jwt_data = { - "sub": "fmtm|20386219", + # Refresh the temp cookies (we must re-create the 'sub' field) + temp_jwt_details = { + **current_user.model_dump(exclude=["id"]), + "sub": f"fmtm|{current_user.id}", "aud": settings.FMTM_DOMAIN, "iat": int(time()), "exp": int(time()) + 86400, # set token expiry to 1 day - "username": username, - "picture": None, - "role": UserRole.MAPPER, } - access_token, refresh_token = create_jwt_tokens(jwt_data) - response = set_cookies( - access_token, + + fmtm_token, refresh_token = create_jwt_tokens(temp_jwt_details) + # NOTE be sure to not append content=current_user.model_dump() to this JSONResponse + # as we want the login state on the frontend to remain empty (allowing the user to + # log in via OSM instead / override) + response = JSONResponse(status_code=HTTPStatus.OK, content={}) + return set_cookies( + response, + fmtm_token, refresh_token, f"{settings.cookie_name}_temp", f"{settings.cookie_name}_temp_refresh", ) - return JSONResponse( - status=response.status, - headers=response.headers, - content=jwt_data, - ) diff --git a/src/backend/app/auth/auth_schemas.py b/src/backend/app/auth/auth_schemas.py index db1527e0fb..baa8f2f826 100644 --- a/src/backend/app/auth/auth_schemas.py +++ b/src/backend/app/auth/auth_schemas.py @@ -38,15 +38,22 @@ class ProjectUserDict(TypedDict): project: DbProject -class AuthUser(BaseModel): - """The user model returned from OSM OAuth2.""" +class BaseUser(BaseModel): + """Base user model to inherit.""" model_config = ConfigDict(use_enum_values=True) username: str + # TODO any usage of profile_img should be refactored out + # in place of 'picture' + profile_img: Optional[str] = None picture: Optional[str] = None role: Optional[UserRole] = UserRole.MAPPER + +class AuthUser(BaseUser): + """The user model returned from OSM OAuth2.""" + _sub: str = PrivateAttr() # it won't return this field def __init__(self, sub: str, **data): @@ -61,6 +68,14 @@ def id(self) -> int: sub = self._sub return int(sub.split("|")[1]) + def model_post_init(self, ctx): + """Temp workaround to convert oauth picture --> profile_img. + + TODO profile_img is used in the db for now, but will be refactored. + """ + if self.picture: + self.profile_img = self.picture + # NOTE we no longer use this, but is present as an example # class AuthUserWithToken(AuthUser): @@ -68,16 +83,18 @@ def id(self) -> int: # token: str -class FMTMUser(BaseModel): - """User details returned to the frontend. - - TODO this should inherit from AuthUser and extend. - TODO profile_img should be refactored to `picture`. - """ +class FMTMUser(BaseUser): + """User details returned to the frontend.""" id: int - username: str - profile_img: str - role: UserRole project_roles: Optional[dict[int, ProjectRole]] = None orgs_managed: Optional[list[int]] = None + + def model_post_init(self, ctx): + """Add to picture field, and remove the value for profile_img. + + We need this workaround as OSM returns profile_img in the response. + """ + if self.profile_img: + self.picture = self.profile_img + self.profile_img = None diff --git a/src/backend/app/auth/providers/osm.py b/src/backend/app/auth/providers/osm.py index 728e984a19..04268275ca 100644 --- a/src/backend/app/auth/providers/osm.py +++ b/src/backend/app/auth/providers/osm.py @@ -21,13 +21,13 @@ import os from time import time -from fastapi import Request +from fastapi import Request, Response from loguru import logger as log from osm_login_python.core import Auth from app.auth.auth_deps import create_jwt_tokens, set_cookies from app.config import settings -from app.db.enums import UserRole +from app.db.enums import HTTPStatus, UserRole if settings.DEBUG: # Required as callback url is http during dev @@ -82,9 +82,14 @@ async def handle_osm_callback(request: Request, osm_auth: Auth): # Create our JWT tokens from user data fmtm_token, refresh_token = create_jwt_tokens(user_data) - response_plus_cookies = set_cookies(fmtm_token, refresh_token) - - # Get OSM token from response (serialised in cookie, deserialise to use) + response = Response(status_code=HTTPStatus.OK) + response_plus_cookies = set_cookies(response, fmtm_token, refresh_token) + + # NOTE Here we create a separate cookie to store the OSM token, for later + # workflows such as conflation (OSM changesets) or OSM messaging. + # First, we get the OSM token from response. + # The token is serialised in the cookie & must be deserialised using + # osm_login_python.auth.deserialize_data. serialised_osm_token = tokens.get("oauth_token") cookie_name = settings.cookie_name osm_cookie_name = f"{cookie_name}_osm" diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index e60181f685..92e3d1b603 100755 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -2,8 +2,8 @@ import React, { useEffect } from 'react'; import { RouterProvider } from 'react-router-dom'; import { Provider, useDispatch } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; -import CoreModules from '@/shared/CoreModules'; import { LoginActions } from '@/store/slices/LoginSlice'; +import { refreshCookies, getUserDetailsFromApi } from '@/utilfunctions/login'; // import '@hotosm/ui/components/Tracking'; import '@hotosm/ui/dist/style.css'; @@ -14,40 +14,35 @@ import AppRoutes from '@/routes'; import { store, persistor } from '@/store/Store'; import OfflineReadyPrompt from '@/components/OfflineReadyPrompt'; -const CheckLoginState = () => { +const RefreshUserCookies = () => { const dispatch = useDispatch(); - const authDetails = CoreModules.useAppSelector((state) => state.login.authDetails); - const checkIfUserLoginValid = () => { - fetch(`${import.meta.env.VITE_API_URL}/auth/refresh/management`, { credentials: 'include' }) - .then((resp) => { - if (resp.status !== 200) { - dispatch(LoginActions.signOut()); - return; - } - return resp.json(); - }) - .then((apiUser) => { - if (!apiUser) return; - - if (apiUser.username !== authDetails?.username) { - // Mismatch between store user and logged in user via api - dispatch(LoginActions.signOut()); + useEffect(() => { + const refreshUserDetails = async () => { + try { + if (!window.location.pathname.includes('osmauth')) { + // Do not do this on the /osmauth page after OSM callback / redirect + const refreshSuccess = await refreshCookies(); + if (refreshSuccess) { + // Call /auth/me to populate the user details in the header + const apiUser = await getUserDetailsFromApi(); + if (apiUser) { + dispatch(LoginActions.setAuthDetails(apiUser)); + // To prevent calls to /auth/me in future (on mapper frontend) + // We still require this here to retrieve role info for the user + localStorage.setItem('fmtm-user-exists', 'true'); + } else { + console.error('Failed to fetch user details after cookie refresh.'); + } + } } - }) - .catch((error) => { - console.log(error); - }); - }; + } catch (err) { + console.error('Unexpected error in RefreshUserCookies:', err); + } + }; - useEffect(() => { - // Check current login state (omit callback url) - if (!window.location.pathname.includes('osmauth')) { - // No need for token refresh check if user details are not set - if (!authDetails) return; - checkIfUserLoginValid(); - } - }, [authDetails]); + refreshUserDetails(); + }, [dispatch]); return null; // Renders nothing }; @@ -57,7 +52,7 @@ const App = () => { - + diff --git a/src/frontend/src/components/LoginPopup.tsx b/src/frontend/src/components/LoginPopup.tsx index 9031430305..bcc9ef4247 100644 --- a/src/frontend/src/components/LoginPopup.tsx +++ b/src/frontend/src/components/LoginPopup.tsx @@ -33,7 +33,7 @@ const LoginPopup = () => { const handleSignIn = async (selectedOption: string) => { if (selectedOption === 'osm_account') { - localStorage.setItem('requestedPath', from); + sessionStorage.setItem('requestedPath', from); osmLoginRedirect(); } }; diff --git a/src/frontend/src/store/slices/LoginSlice.ts b/src/frontend/src/store/slices/LoginSlice.ts index f1b420a61e..075ccad4ae 100755 --- a/src/frontend/src/store/slices/LoginSlice.ts +++ b/src/frontend/src/store/slices/LoginSlice.ts @@ -15,7 +15,6 @@ const LoginSlice = CoreModules.createSlice({ state.authDetails = action.payload; }, signOut(state) { - storage.removeItem('persist:login'); state.authDetails = null; }, setLoginModalOpen(state, action) { diff --git a/src/frontend/src/utilfunctions/login.ts b/src/frontend/src/utilfunctions/login.ts index 777fcc2137..a50a04ec3b 100644 --- a/src/frontend/src/utilfunctions/login.ts +++ b/src/frontend/src/utilfunctions/login.ts @@ -1,19 +1,35 @@ // The /auth/me endpoint does an UPSERT in the database, ensuring the user // exists in the FMTM DB export const getUserDetailsFromApi = async () => { - const resp = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, { - credentials: 'include', - }); + try { + const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/me`, { + credentials: 'include', + }); - if (resp.status !== 200) { - return false; + if (!response.ok) { + throw new Error(`Status: ${response.status}`); + } + + return response.json(); + } catch (err) { + console.error('Error retrieving user details:', err); } +}; - const apiUser = await resp.json(); +export const refreshCookies = async () => { + try { + const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/refresh/management`, { + credentials: 'include', + }); - if (!apiUser) return false; + if (!response.ok) { + return false; + } - return apiUser; + return true; + } catch (err) { + return false; + } }; export const osmLoginRedirect = async () => { @@ -26,7 +42,7 @@ export const osmLoginRedirect = async () => { } }; -export const revokeCookie = async () => { +export const revokeCookies = async () => { try { const response = await fetch(`${import.meta.env.VITE_API_URL}/auth/logout`, { credentials: 'include' }); if (!response.ok) { diff --git a/src/frontend/src/utilities/CustomDrawer.tsx b/src/frontend/src/utilities/CustomDrawer.tsx index 19468b4bb3..60d3ce610f 100644 --- a/src/frontend/src/utilities/CustomDrawer.tsx +++ b/src/frontend/src/utilities/CustomDrawer.tsx @@ -4,11 +4,10 @@ import Button from '@/components/common/Button'; import CoreModules from '@/shared/CoreModules'; import AssetModules from '@/shared/AssetModules'; import { NavLink } from 'react-router-dom'; -import { revokeCookie } from '@/utilfunctions/login'; +import { revokeCookies } from '@/utilfunctions/login'; import { CommonActions } from '@/store/slices/CommonSlice'; import { LoginActions } from '@/store/slices/LoginSlice'; import { ProjectActions } from '@/store/slices/ProjectSlice'; -import DebugConsole from '@/utilities/DebugConsole'; import { useAppSelector } from '@/types/reduxTypes'; type customDrawerType = { @@ -74,7 +73,6 @@ export default function CustomDrawer({ open, size, type, onClose, setOpen }: cus const dispatch = CoreModules.useAppDispatch(); const defaultTheme = useAppSelector((state) => state.theme.hotTheme); - const [showDebugConsole, setShowDebugConsole] = useState(false); const authDetails = CoreModules.useAppSelector((state) => state.login.authDetails); const onMouseEnter = (event: React.MouseEvent) => { @@ -112,7 +110,7 @@ export default function CustomDrawer({ open, size, type, onClose, setOpen }: cus const handleOnSignOut = async () => { setOpen(false); try { - await revokeCookie(); + await revokeCookies(); dispatch(LoginActions.signOut()); dispatch(ProjectActions.clearProjects([])); } catch { @@ -129,7 +127,6 @@ export default function CustomDrawer({ open, size, type, onClose, setOpen }: cus return (
- @@ -153,12 +150,12 @@ export default function CustomDrawer({ open, size, type, onClose, setOpen }: cus ml={'3%'} spacing={1} > - {authDetails['profile_img'] !== 'null' && authDetails['profile_img'] ? ( + {authDetails['picture'] !== 'null' && authDetails['picture'] ? ( - Profile Picture + Profile Picture ) : ( @@ -223,9 +220,6 @@ export default function CustomDrawer({ open, size, type, onClose, setOpen }: cus ), )} - {import.meta.env.MODE === 'development' && ( - - {logs.map((log, index) => ( -

{log}

- ))} -
- - )} - - ); -}; - -export default DebugConsole; diff --git a/src/frontend/src/utilities/PrimaryAppBar.tsx b/src/frontend/src/utilities/PrimaryAppBar.tsx index 116933a960..60ad39516b 100755 --- a/src/frontend/src/utilities/PrimaryAppBar.tsx +++ b/src/frontend/src/utilities/PrimaryAppBar.tsx @@ -6,7 +6,7 @@ import AssetModules from '@/shared/AssetModules'; import { CommonActions } from '@/store/slices/CommonSlice'; import { LoginActions } from '@/store/slices/LoginSlice'; import { ProjectActions } from '@/store/slices/ProjectSlice'; -import { revokeCookie } from '@/utilfunctions/login'; +import { revokeCookies } from '@/utilfunctions/login'; import { useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import logo from '@/assets/images/hotLog.png'; @@ -48,7 +48,7 @@ export default function PrimaryAppBar() { const handleOnSignOut = async () => { setOpen(false); try { - await revokeCookie(); + await revokeCookies(); dispatch(LoginActions.signOut()); dispatch(ProjectActions.clearProjects([])); } catch { @@ -111,12 +111,12 @@ export default function PrimaryAppBar() { alignItems="center" className="fmtm-text-ellipsis fmtm-max-w-[9.5rem]" > - {authDetails['profile_img'] !== 'null' && authDetails['profile_img'] ? ( + {authDetails['picture'] !== 'null' && authDetails['picture'] ? ( - Profile Picture + Profile Picture ) : ( diff --git a/src/frontend/src/views/OsmAuth.tsx b/src/frontend/src/views/OsmAuth.tsx index 21e6fa8527..416c3917b4 100644 --- a/src/frontend/src/views/OsmAuth.tsx +++ b/src/frontend/src/views/OsmAuth.tsx @@ -9,7 +9,8 @@ function OsmAuth() { const location = useLocation(); const dispatch = CoreModules.useAppDispatch(); const [isReadyToRedirect, setIsReadyToRedirect] = useState(false); - const requestedPath = localStorage.getItem('requestedPath'); + const [error, setError] = useState(null); + const requestedPath = sessionStorage.getItem('requestedPath'); useEffect(() => { // Redirect workaround required for localhost, until PR is merged: @@ -27,33 +28,51 @@ function OsmAuth() { const loginRedirect = async () => { // authCode is passed from OpenStreetMap redirect, so get cookie, then redirect if (authCode) { - const callbackUrl = `${import.meta.env.VITE_API_URL}/auth/callback?code=${authCode}&state=${state}`; - - const completeLogin = async () => { - // NOTE this encapsulates async methods to call sequentially - // Sets a cookie in the browser that is used for auth - await fetch(callbackUrl, { credentials: 'include' }); - const apiUser = await getUserDetailsFromApi(); - dispatch(LoginActions.setAuthDetails(apiUser)); - }; - await completeLogin(); - } + try { + const response = await fetch( + `${import.meta.env.VITE_API_URL}/auth/callback?code=${authCode}&state=${state}`, + { credentials: 'include' }, + ); + + if (!response.ok) { + throw new Error(`Callback request failed with status ${response.status}`); + } - setIsReadyToRedirect(true); - dispatch(LoginActions.setLoginModalOpen(false)); + setIsReadyToRedirect(true); + dispatch(LoginActions.setLoginModalOpen(false)); - if (requestedPath) { - if (requestedPath.includes('mapnow')) { - // redirect to mapper frontend (navigate doesn't work as it's on svelte) - window.location.href = `${window.location.origin}${requestedPath}`; - } else { - navigate(`${requestedPath}`); - localStorage.removeItem('requestedPath'); + if (requestedPath) { + sessionStorage.removeItem('requestedPath'); + if (requestedPath.includes('mapnow')) { + // redirect to mapper frontend (navigate doesn't work as it's on svelte) + window.location.href = `${window.location.origin}${requestedPath}`; + } else { + // Call /auth/me to populate the user details in the header + const apiUser = await getUserDetailsFromApi(); + if (apiUser) { + dispatch(LoginActions.setAuthDetails(apiUser)); + // To prevent calls to /auth/me in future + localStorage.setItem('fmtm-user-exists', 'true'); + } else { + console.error('Failed to fetch user details after cookie refresh.'); + } + // Then navigate to the originally requested url + navigate(`${requestedPath}`); + } + } + } catch (err) { + console.error('Error during callback:', err); + setError('Failed to authenticate. Please try again.'); } } }; + loginRedirect(); - }, [dispatch, location.search, navigate]); + }, [dispatch, location.search, navigate, requestedPath]); + + if (error) { + return
Error: {error}
; + } return <>{!isReadyToRedirect ? null :
redirecting
}; } diff --git a/src/frontend/src/views/PlaywrightTempLogin.tsx b/src/frontend/src/views/PlaywrightTempLogin.tsx index ec8b34f8a3..d5c2dbe236 100644 --- a/src/frontend/src/views/PlaywrightTempLogin.tsx +++ b/src/frontend/src/views/PlaywrightTempLogin.tsx @@ -2,8 +2,7 @@ // the auth is overridden when testing due to DEBUG=True on the backend, // but we need to update the frontend state to show we are logged in -import axios from 'axios'; -import { getUserDetailsFromApi } from '@/utilfunctions/login'; +import { refreshCookies, getUserDetailsFromApi } from '@/utilfunctions/login'; import { CommonActions } from '@/store/slices/CommonSlice'; import CoreModules from '@/shared/CoreModules.js'; import { LoginActions } from '@/store/slices/LoginSlice'; @@ -11,22 +10,25 @@ import { LoginActions } from '@/store/slices/LoginSlice'; async function PlaywrightTempAuth() { const dispatch = CoreModules.useAppDispatch(); // Sets a cookie in the browser that is used for auth - await axios.get(`${import.meta.env.VITE_API_URL}/auth/refresh/management`); - - const apiUser = await getUserDetailsFromApi(); - if (!apiUser) { - dispatch( - CommonActions.SetSnackBar({ - open: true, - message: 'Temp login failed. Try OSM.', - variant: 'error', - duration: 2000, - }), - ); - return; + await refreshCookies(); + const refreshSuccess = await refreshCookies(); + if (!refreshSuccess) { + const apiUser = await getUserDetailsFromApi(); + if (!apiUser) { + dispatch( + CommonActions.SetSnackBar({ + open: true, + message: 'Temp login failed. Try OSM.', + variant: 'error', + duration: 2000, + }), + ); + return; + } + dispatch(LoginActions.setAuthDetails(apiUser)); + } else { + console.error('Failed to refresh cookies.'); } - - dispatch(LoginActions.setAuthDetails(apiUser)); } export default PlaywrightTempAuth; diff --git a/src/frontend/src/views/ProjectDetailsV2.tsx b/src/frontend/src/views/ProjectDetailsV2.tsx index 4438fefe73..b1cb292918 100644 --- a/src/frontend/src/views/ProjectDetailsV2.tsx +++ b/src/frontend/src/views/ProjectDetailsV2.tsx @@ -34,7 +34,6 @@ import { useAppSelector } from '@/types/reduxTypes'; import Comments from '@/components/ProjectDetailsV2/Comments'; import { Geolocation } from '@/utilfunctions/Geolocation'; import Instructions from '@/components/ProjectDetailsV2/Instructions'; -import DebugConsole from '@/utilities/DebugConsole'; import { CustomCheckbox } from '@/components/common/Checkbox'; import useDocumentTitle from '@/utilfunctions/useDocumentTitle'; import QrcodeComponent from '@/components/QrcodeComponent'; @@ -252,13 +251,8 @@ const ProjectDetailsV2 = () => { dispatch(GetEntityInfo(`${import.meta.env.VITE_API_URL}/projects/${projectId}/entities/statuses`)); }, []); - const [showDebugConsole, setShowDebugConsole] = useState(false); - return (
- {/* only used to display debug console */} - - {/* Customized Modal For Generate Tiles */}
@@ -409,18 +403,6 @@ const ProjectDetailsV2 = () => { windowSize.width <= 640 ? '!fmtm-h-[100dvh]' : '!fmtm-h-full' }`} > - {import.meta.env.MODE === 'development' && ( -
- { - setShowDebugConsole(status); - }} - className="fmtm-text-black !fmtm-w-full" - /> -
- )} {taskBoundariesLayer && taskBoundariesLayer?.features?.length > 0 && ( diff --git a/src/mapper/src/lib/components/header.svelte b/src/mapper/src/lib/components/header.svelte index bbebeab6ab..bbaf1fe7b1 100644 --- a/src/mapper/src/lib/components/header.svelte +++ b/src/mapper/src/lib/components/header.svelte @@ -1,5 +1,4 @@
- -
-
- Manage Basemaps -
-
+ +
+
+ Manage Basemaps +
+
- -
- {#if basemapsAvailable} - +
+ {#if basemapsAvailable} + - { - const selectedId = event.originalTarget.value - selectedBasemap = basemapStore.projectBasemaps?.find((basemap: Basemap) => basemap.id === selectedId) || null - }} - > - {#each basemapStore.projectBasemaps as basemap} - {#if basemap.status === "SUCCESS"} - - {basemap.tile_source} {basemap.format} - - {/if} - {/each} - - {:else} -
-
- There are no basemaps available for this project. -
-
- Please ask the project manager to create basemaps. -
-
- {/if} -
+ { + const selectedId = event.target.value; + selectedBasemap = basemapStore.projectBasemaps?.find((basemap: Basemap) => basemap.id === selectedId) || null; + }} + > + {#each basemapStore.projectBasemaps as basemap} + {#if basemap.status === 'SUCCESS'} + + {basemap.tile_source} + {basemap.format} + + {/if} + {/each} + + {:else} +
+
There are no basemaps available for this project.
+
Please ask the project manager to create basemaps.
+
+ {/if} +
- - {#if selectedBasemap && selectedBasemap.format === 'pmtiles' } - loadOnlinePmtiles(selectedBasemap.url)} - onkeydown={(e: KeyboardEvent) => { - e.key === 'Enter' && loadOnlinePmtiles(selectedBasemap.url); - }} - role="button" - tabindex="0" - size="small" - class="secondary w-full max-w-[200px]" - > - - Show On Map - + + {#if selectedBasemap && selectedBasemap.format === 'pmtiles'} + loadOnlinePmtiles(selectedBasemap?.url)} + onkeydown={(e: KeyboardEvent) => { + e.key === 'Enter' && loadOnlinePmtiles(selectedBasemap?.url); + }} + role="button" + tabindex="0" + size="small" + class="secondary w-full max-w-[200px]" + > + + Show On Map + - writeOfflinePmtiles(projectId, selectedBasemap.url)} - onkeydown={(e: KeyboardEvent) => { - e.key === 'Enter' && writeOfflinePmtiles(projectId, selectedBasemap.url); - }} - role="button" - tabindex="0" - size="small" - class="secondary w-full max-w-[200px]" - > - - Store Offline - + writeOfflinePmtiles(projectId, selectedBasemap?.url)} + onkeydown={(e: KeyboardEvent) => { + e.key === 'Enter' && writeOfflinePmtiles(projectId, selectedBasemap?.url); + }} + role="button" + tabindex="0" + size="small" + class="secondary w-full max-w-[200px]" + > + + Store Offline + - - {:else if selectedBasemap && selectedBasemap.format === 'mbtiles' } - window.open(selectedBasemap.url)} - onkeydown={(e: KeyboardEvent) => { - e.key === 'Enter' && window.open(selectedBasemap.url); - }} - role="button" - tabindex="0" - size="small" - class="secondary w-full max-w-[200px]" - > - - Download MBTiles - - {/if} + + {:else if selectedBasemap && selectedBasemap.format === 'mbtiles'} + window.open(selectedBasemap?.url)} + onkeydown={(e: KeyboardEvent) => { + e.key === 'Enter' && window.open(selectedBasemap?.url); + }} + role="button" + tabindex="0" + size="small" + class="secondary w-full max-w-[200px]" + > + + Download MBTiles + + {/if} - {@render children?.()} + {@render children?.()}
diff --git a/src/mapper/src/lib/utils/basemaps.ts b/src/mapper/src/lib/utils/basemaps.ts index 485ec106da..f300ae40b8 100644 --- a/src/mapper/src/lib/utils/basemaps.ts +++ b/src/mapper/src/lib/utils/basemaps.ts @@ -38,7 +38,7 @@ export async function getBasemapList(projectId: number): Promise { } } -export async function loadOnlinePmtiles(url: string | null) { +export async function loadOnlinePmtiles(url: string | undefined) { if (!url) return; const pmtilesUrl = `pmtiles://${url}`; @@ -60,7 +60,7 @@ export async function loadOfflinePmtiles(projectId: number) { basemapStore.setProjectPmtilesUrl(pmtilesUrl); } -async function downloadBasemap(url: string | null): Promise { +async function downloadBasemap(url: string | undefined): Promise { let basemapData: ArrayBuffer = new ArrayBuffer(0); if (!url) return basemapData; @@ -87,7 +87,7 @@ async function downloadBasemap(url: string | null): Promise { } } -export async function writeOfflinePmtiles(projectId: number, url: string | null) { +export async function writeOfflinePmtiles(projectId: number, url: string | undefined) { const data = await downloadBasemap(url); // Copy to OPFS filesystem for offline use From 259c2564880fbfd3a218a05e10911d2ebfe5834f Mon Sep 17 00:00:00 2001 From: spwoodcock Date: Sun, 15 Dec 2024 23:33:51 +0000 Subject: [PATCH 15/15] fix(frontend): small fix to reset frontend login if cookie refresh fails --- src/frontend/src/App.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index c5d83aa561..8138f0a963 100755 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -31,6 +31,9 @@ const RefreshUserCookies = () => { } else { console.error('Failed to fetch user details after cookie refresh.'); } + } else { + // Reset frontend login state on 401 + dispatch(LoginActions.setAuthDetails(null)); } } } catch (err) {