{errorMessage ? (
@@ -88,4 +99,5 @@ PureCustomSignUp.prototype = {
GoogleLoginButton: PropTypes.node,
classes: PropTypes.objectOf(PropTypes.object),
errorMessage: PropTypes.string,
+ isDashboardSignIn: PropTypes.bool,
};
diff --git a/packages/webapp/src/components/CustomSignUp/styles.module.scss b/packages/webapp/src/components/CustomSignUp/styles.module.scss
index 13821a8192..2834b98a01 100644
--- a/packages/webapp/src/components/CustomSignUp/styles.module.scss
+++ b/packages/webapp/src/components/CustomSignUp/styles.module.scss
@@ -23,6 +23,26 @@
align-items: center;
}
+.dashboardSignIn {
+ padding-top: 24px;
+ max-width: 312px;
+ text-align: center;
+}
+
+.dashboardSignInTitle {
+ font-size: 18px;
+ font-weight: 600;
+ line-height: 24px;
+ color: var(--fontColor);
+}
+
+.dashboardSignInSubtitle {
+ padding-top: 4px;
+ font-size: 14px;
+ line-height: 20px;
+ color: var(--grey600);
+}
+
.ssoButton {
margin: auto;
padding-top: 32px;
diff --git a/packages/webapp/src/containers/ChooseFarm/saga.js b/packages/webapp/src/containers/ChooseFarm/saga.js
index 87f8388bef..2e0f0c68fb 100644
--- a/packages/webapp/src/containers/ChooseFarm/saga.js
+++ b/packages/webapp/src/containers/ChooseFarm/saga.js
@@ -40,6 +40,11 @@ export function* getUserFarmsSaga() {
const { userFarmUrl } = apiConfig;
try {
const { user_id } = yield select(loginSelector);
+ if (!user_id) {
+ // Without an identity the request cannot succeed, and the catch below dispatches
+ // onLoadingUserFarmsFail, which sets loaded: true.
+ return;
+ }
const header = getHeader(user_id);
yield put(onLoadingUserFarmsStart());
const result = yield call(axios.get, userFarmUrl + '/user/' + user_id, header);
diff --git a/packages/webapp/src/containers/CustomSignUp/index.jsx b/packages/webapp/src/containers/CustomSignUp/index.jsx
index 86a57d3e69..e30aa2ca0c 100644
--- a/packages/webapp/src/containers/CustomSignUp/index.jsx
+++ b/packages/webapp/src/containers/CustomSignUp/index.jsx
@@ -21,6 +21,7 @@ import {
import { isChrome } from '../../util';
import { getLanguageFromLocalStorage } from '../../util/getLanguageFromLocalStorage';
import { customSignUpErrorKeySelector, setCustomSignUpErrorKey } from '../customSignUpSlice';
+import { getDashboardReturnTo } from '../dashboardReturnTo';
import { VALID_EMAIL_REGEX } from '../../util/validation';
const ResetPassword = React.lazy(() => import('../ResetPassword'));
@@ -59,6 +60,7 @@ function CustomSignUp() {
const { t, i18n, ready } = useTranslation(['translation', 'common'], { useSuspense: false });
const customSignUpErrorKey = useSelector(customSignUpErrorKeySelector);
+ const dashboardReturnTo = getDashboardReturnTo();
const [submittedEmail, setSubmittedEmail] = useState('');
const forgotPassword = () => {
@@ -173,6 +175,7 @@ function CustomSignUp() {
GoogleLoginButton={
}
isChrome={isChrome()}
errorMessage={errorMessage}
+ isDashboardSignIn={!!dashboardReturnTo}
inputs={[
{
label: t('SIGNUP.ENTER_EMAIL'),
diff --git a/packages/webapp/src/containers/CustomSignUp/saga.js b/packages/webapp/src/containers/CustomSignUp/saga.js
index c91a01d32b..ca731549a6 100644
--- a/packages/webapp/src/containers/CustomSignUp/saga.js
+++ b/packages/webapp/src/containers/CustomSignUp/saga.js
@@ -25,6 +25,7 @@ import { axios } from '../saga';
import { enqueueErrorSnackbar } from '../Snackbar/snackbarSlice';
import { getLanguageFromLocalStorage } from '../../util/getLanguageFromLocalStorage';
import { setCustomSignUpErrorKey, setPasswordResetError } from '../customSignUpSlice';
+import { handOffToDashboardIfRequested } from '../dashboardTicketHandoff';
const loginUrl = (email) => `${url}/login/user/${email}`;
const loginWithPasswordUrl = () => `${url}/login`;
@@ -93,7 +94,10 @@ export function* customLoginWithPasswordSaga({ payload: { showPasswordError, ...
localStorage.setItem('id_token', id_token);
yield put(loginSuccess({ user_id }));
- history.push('/farm_selection');
+ const handedOff = yield call(handOffToDashboardIfRequested);
+ if (!handedOff) {
+ history.push('/farm_selection');
+ }
} catch (e) {
if (e.response?.status === 401) {
showPasswordError();
@@ -137,7 +141,10 @@ export function* customCreateUserSaga({ payload: data }) {
localStorage.setItem('litefarm_lang', language_preference);
yield put(loginSuccess({ user_id }));
- history.push('/farm_selection');
+ const handedOff = yield call(handOffToDashboardIfRequested);
+ if (!handedOff) {
+ history.push('/farm_selection');
+ }
}
} catch (e) {
yield put(enqueueErrorSnackbar(i18n.t('message:USER.ERROR.INVITE')));
diff --git a/packages/webapp/src/containers/GoogleLoginButton/saga.js b/packages/webapp/src/containers/GoogleLoginButton/saga.js
index 11d37ed097..d9abceaeed 100644
--- a/packages/webapp/src/containers/GoogleLoginButton/saga.js
+++ b/packages/webapp/src/containers/GoogleLoginButton/saga.js
@@ -10,6 +10,7 @@ import { enqueueErrorSnackbar } from '../Snackbar/snackbarSlice';
import { getLanguageFromLocalStorage } from '../../util/getLanguageFromLocalStorage';
import { setCustomSignUpErrorKey } from '../customSignUpSlice';
import { inlineErrors } from '../CustomSignUp/constants';
+import { handOffToDashboardIfRequested } from '../dashboardTicketHandoff';
const loginUrl = () => `${url}/google`;
@@ -49,10 +50,13 @@ export function* loginWithGoogleSaga({ payload: google_id_token }) {
);
} else {
yield put(loginSuccess(user));
- if (isSignUp) {
- history.push('/welcome');
- } else {
- history.push('/farm_selection');
+ const handedOff = yield call(handOffToDashboardIfRequested);
+ if (!handedOff) {
+ if (isSignUp) {
+ history.push('/welcome');
+ } else {
+ history.push('/farm_selection');
+ }
}
}
} catch (e) {
diff --git a/packages/webapp/src/containers/dashboardReturnTo.ts b/packages/webapp/src/containers/dashboardReturnTo.ts
new file mode 100644
index 0000000000..47c65f612d
--- /dev/null
+++ b/packages/webapp/src/containers/dashboardReturnTo.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see
.
+ */
+
+import { getReturnToFromSearch } from '../util/dashboardTicket';
+
+/**
+ * The Analytics Dashboard return address, read from the URL query string when this module first
+ * loads. `CustomSignUp` later calls `history.replace` with a URL that has no query string, so
+ * reading it any later finds nothing.
+ *
+ * Not stored anywhere: a reload or a second tab starts from whatever its own URL carries.
+ */
+let returnTo: string | null = getReturnToFromSearch(window.location.search);
+
+export function getDashboardReturnTo(): string | null {
+ return returnTo;
+}
+
+export function clearDashboardReturnTo(): void {
+ returnTo = null;
+}
diff --git a/packages/webapp/src/containers/dashboardTicketHandoff.ts b/packages/webapp/src/containers/dashboardTicketHandoff.ts
new file mode 100644
index 0000000000..37886548a4
--- /dev/null
+++ b/packages/webapp/src/containers/dashboardTicketHandoff.ts
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see
.
+ */
+
+import type { AnyAction, ThunkDispatch } from '@reduxjs/toolkit';
+import i18n from '../locales/i18n';
+import { store, RootState } from '../store/store';
+import { dashboardTicketApi } from '../store/api/dashboardTicketApi';
+import { buildDashboardTicketUrl } from '../util/dashboardTicket';
+import { logout } from '../util/jwt';
+import { enqueueErrorSnackbar } from './Snackbar/snackbarSlice';
+import { clearDashboardReturnTo, getDashboardReturnTo } from './dashboardReturnTo';
+
+/**
+ * Requests a ticket and sends the browser to the Analytics Dashboard when the user arrived
+ * with a return address. Resolves true when it has navigated away, false when the caller
+ * should continue to its ordinary destination.
+ */
+export async function handOffToDashboardIfRequested(): Promise
{
+ const returnTo = getDashboardReturnTo();
+
+ if (!returnTo) {
+ return false;
+ }
+
+ // store.ts types its middleware as Middleware[], which narrows store.dispatch to plain actions
+ const dispatch = store.dispatch as ThunkDispatch;
+
+ const request = dispatch(
+ dashboardTicketApi.endpoints.createDashboardTicket.initiate({ return_to: returnTo }),
+ );
+
+ try {
+ const { ticket, return_to } = await request.unwrap();
+ clearDashboardReturnTo();
+ window.location.replace(buildDashboardTicketUrl(return_to, ticket));
+ return true;
+ } catch (e) {
+ console.error(e);
+
+ // Sign out, but keep the address so the sign-in that follows completes the hand-off
+ if (typeof e === 'object' && e !== null && 'status' in e && e.status === 401) {
+ logout();
+ return false;
+ }
+
+ dispatch(enqueueErrorSnackbar(i18n.t('message:LOGIN.ERROR.DASHBOARD_TICKET')));
+ clearDashboardReturnTo();
+ return false;
+ } finally {
+ // The ticket is a credential, and a mutation result stays in the RTK Query cache until reset
+ request.reset();
+ }
+}
diff --git a/packages/webapp/src/containers/hooks/useDashboardHandoff.ts b/packages/webapp/src/containers/hooks/useDashboardHandoff.ts
new file mode 100644
index 0000000000..bca4f99025
--- /dev/null
+++ b/packages/webapp/src/containers/hooks/useDashboardHandoff.ts
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import { useEffect, useState } from 'react';
+import { getDashboardReturnTo } from '../dashboardReturnTo';
+import { handOffToDashboardIfRequested } from '../dashboardTicketHandoff';
+
+/**
+ * Starts the hand-off to the Analytics Dashboard when the user arrived with a return address and
+ * is already signed in.
+ *
+ * Returns true until the ticket request finishes, so the caller can render a Spinner instead of a
+ * route. The first value is computed during the first render, so no route renders before the
+ * redirect.
+ *
+ * @param isSignedIn whether the browser holds a session the app can use.
+ */
+export default function useDashboardHandoff(isSignedIn: boolean): boolean {
+ const [isHandingOff, setIsHandingOff] = useState(() => !!getDashboardReturnTo() && isSignedIn);
+
+ useEffect(() => {
+ if (!isHandingOff) {
+ return;
+ }
+
+ // On success the browser has already left LiteFarm, so only a failure stops the Spinner
+ handOffToDashboardIfRequested().then((handedOff) => {
+ if (!handedOff) {
+ setIsHandingOff(false);
+ }
+ });
+ }, []);
+
+ return isHandingOff;
+}
diff --git a/packages/webapp/src/hooks/useAuthenticatedSession.ts b/packages/webapp/src/hooks/useAuthenticatedSession.ts
new file mode 100644
index 0000000000..4c7b6120ab
--- /dev/null
+++ b/packages/webapp/src/hooks/useAuthenticatedSession.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import { useSelector } from 'react-redux';
+import { userFarmReducerSelector } from '../containers/userFarmSlice';
+import type { RootState } from '../store/store';
+import { isAuthenticated } from '../util/jwt';
+
+/**
+ * Reports whether the browser holds a session the app can render.
+ *
+ * A session needs two values that are written to browser storage separately: `id_token` in
+ * `localStorage`, and `user_id` in the persisted Redux store. They can disagree. `localStorage` is
+ * shared between tabs and each tab writes the whole of `persist:root` on any state change, so a
+ * tab holding no identity overwrites a signed-in tab's `user_id` and leaves the token behind.
+ *
+ * A token on its own admits nothing: every farm selector filters on `user_id` and returns empty,
+ * so the app has no farm to render.
+ */
+export default function useAuthenticatedSession(): boolean {
+ const hasIdentity = useSelector((state: RootState) => !!userFarmReducerSelector(state).user_id);
+
+ return hasIdentity && isAuthenticated();
+}
diff --git a/packages/webapp/src/main.jsx b/packages/webapp/src/main.jsx
index 705b703249..1ceb665906 100644
--- a/packages/webapp/src/main.jsx
+++ b/packages/webapp/src/main.jsx
@@ -69,7 +69,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
integrations: [new Integrations.BrowserTracing()],
- release: '3.13.0',
+ release: '3.13.1',
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
diff --git a/packages/webapp/src/routes/index.jsx b/packages/webapp/src/routes/index.jsx
index 8735a26e75..e238dca5fa 100644
--- a/packages/webapp/src/routes/index.jsx
+++ b/packages/webapp/src/routes/index.jsx
@@ -22,12 +22,13 @@ import Spinner from '../components/Spinner';
import OnboardingFlow from './Onboarding';
import CustomSignUp from '../containers/CustomSignUp';
import { useSelector } from 'react-redux';
-import { isAuthenticated } from '../util/jwt';
+import useAuthenticatedSession from '../hooks/useAuthenticatedSession';
// action
import { userFarmSelector } from '../containers/userFarmSlice';
import { chooseFarmFlowSelector } from '../containers/ChooseFarm/chooseFarmFlowSlice';
import useScrollToTop from '../containers/hooks/useScrollToTop';
+import useDashboardHandoff from '../containers/hooks/useDashboardHandoff';
import { useReduxSnackbar } from '../containers/Snackbar/useReduxSnackbar';
import {
@@ -207,6 +208,8 @@ const UnknownRecord = React.lazy(
const Routes = ({ isCompactSideMenu }) => {
useScrollToTop();
useReduxSnackbar();
+ const isSignedIn = useAuthenticatedSession();
+ const isHandingOffToDashboard = useDashboardHandoff(isSignedIn);
const userFarm = useSelector(
userFarmSelector,
(pre, next) =>
@@ -226,13 +229,17 @@ const Routes = ({ isCompactSideMenu }) => {
const hasSelectedFarm = !!farm_id;
const hasFinishedOnBoardingFlow = step_one && step_five;
+ if (isHandingOffToDashboard) {
+ return ;
+ }
+
return (
}>
{
- if (isAuthenticated()) {
+ if (isSignedIn) {
role_id = Number(role_id);
// TODO check every step
if (isInvitationFlow) {
@@ -1055,7 +1062,7 @@ const Routes = ({ isCompactSideMenu }) => {
);
}
- } else if (!isAuthenticated()) {
+ } else {
return (
} />
diff --git a/packages/webapp/src/store/api/dashboardTicketApi.ts b/packages/webapp/src/store/api/dashboardTicketApi.ts
new file mode 100644
index 0000000000..29b9b9ebae
--- /dev/null
+++ b/packages/webapp/src/store/api/dashboardTicketApi.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import { api } from './apiSlice';
+import { loginUrl } from '../../apiConfig';
+
+interface DashboardTicket {
+ ticket: string;
+ return_to: string;
+}
+
+interface CreateDashboardTicketReqBody {
+ return_to: string;
+}
+
+export const dashboardTicketApi = api.injectEndpoints({
+ endpoints: (build) => ({
+ createDashboardTicket: build.mutation({
+ query: (body) => ({
+ url: `${loginUrl}/dashboard/ticket`,
+ method: 'POST',
+ body,
+ }),
+ }),
+ }),
+});
+
+export const { useCreateDashboardTicketMutation } = dashboardTicketApi;
diff --git a/packages/webapp/src/tests/chooseFarmSaga.test.ts b/packages/webapp/src/tests/chooseFarmSaga.test.ts
new file mode 100644
index 0000000000..e9ab58baee
--- /dev/null
+++ b/packages/webapp/src/tests/chooseFarmSaga.test.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import { put, select } from 'redux-saga/effects';
+import { describe, expect, test } from 'vitest';
+import { getUserFarmsSaga } from '../containers/ChooseFarm/saga';
+import { loginSelector, onLoadingUserFarmsStart } from '../containers/userFarmSlice';
+
+const USER_ID = '11111111-1111-1111-1111-111111111111';
+
+describe('getUserFarmsSaga', () => {
+ test('makes no request and dispatches nothing without an identity', () => {
+ const saga = getUserFarmsSaga();
+
+ expect(saga.next().value).toEqual(select(loginSelector));
+
+ const afterSelect = saga.next({ user_id: undefined });
+
+ expect(afterSelect.done).toBe(true);
+ expect(afterSelect.value).toBe(undefined);
+ });
+
+ test('proceeds to the request when an identity is present', () => {
+ const saga = getUserFarmsSaga();
+
+ expect(saga.next().value).toEqual(select(loginSelector));
+ expect(saga.next({ user_id: USER_ID }).value).toEqual(put(onLoadingUserFarmsStart()));
+ });
+});
diff --git a/packages/webapp/src/tests/dashboardTicket.test.js b/packages/webapp/src/tests/dashboardTicket.test.js
new file mode 100644
index 0000000000..4bca277e38
--- /dev/null
+++ b/packages/webapp/src/tests/dashboardTicket.test.js
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+import { expect, describe, test } from 'vitest';
+import { buildDashboardTicketUrl, getReturnToFromSearch } from '../util/dashboardTicket';
+
+describe('getReturnToFromSearch', () => {
+ test('returns the return address', () => {
+ expect(getReturnToFromSearch('?return_to=https://data.litefarm.org/auth/finish')).toBe(
+ 'https://data.litefarm.org/auth/finish',
+ );
+ });
+
+ test('decodes a percent-encoded value', () => {
+ expect(
+ getReturnToFromSearch('?return_to=https%3A%2F%2Fdata.litefarm.org%2Fauth%2Ffinish'),
+ ).toBe('https://data.litefarm.org/auth/finish');
+ });
+
+ test('returns null for an empty search string', () => {
+ expect(getReturnToFromSearch('')).toBe(null);
+ });
+
+ test('returns null when only other parameters are present', () => {
+ expect(getReturnToFromSearch('?farm_id=abc&lang=es')).toBe(null);
+ });
+
+ test('returns null for a present but empty value', () => {
+ expect(getReturnToFromSearch('?return_to=')).toBe(null);
+ });
+});
+
+describe('buildDashboardTicketUrl', () => {
+ test('attaches the ticket', () => {
+ expect(buildDashboardTicketUrl('https://data.litefarm.org/auth/finish', 'abc.def.ghi')).toBe(
+ 'https://data.litefarm.org/auth/finish?ticket=abc.def.ghi',
+ );
+ });
+
+ test('preserves an existing query string', () => {
+ expect(
+ buildDashboardTicketUrl('https://data.litefarm.org/auth/finish?next=%2Ffarms', 'abc.def.ghi'),
+ ).toBe('https://data.litefarm.org/auth/finish?next=%2Ffarms&ticket=abc.def.ghi');
+ });
+
+ test('leaves the path intact', () => {
+ expect(
+ new URL(buildDashboardTicketUrl('https://data.litefarm.org/auth/finish', 'abc')).pathname,
+ ).toBe('/auth/finish');
+ });
+});
diff --git a/packages/webapp/src/tests/useAuthenticatedSession.test.tsx b/packages/webapp/src/tests/useAuthenticatedSession.test.tsx
new file mode 100644
index 0000000000..4f6df9b045
--- /dev/null
+++ b/packages/webapp/src/tests/useAuthenticatedSession.test.tsx
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+import { ReactNode } from 'react';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import { cleanup, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test } from 'vitest';
+// store/reducer pulls in slices that import store/store, and store/store imports store/reducer.
+// Evaluating store/store first keeps that cycle from handing configureStore an undefined reducer.
+import '../store/store';
+import rootReducer from '../store/reducer';
+import { loginSuccess } from '../containers/userFarmSlice';
+import useAuthenticatedSession from '../hooks/useAuthenticatedSession';
+
+const USER_ID = '11111111-1111-1111-1111-111111111111';
+
+const buildStore = ({ userId }: { userId?: string }) => {
+ const store = configureStore({
+ reducer: rootReducer,
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware({ immutableCheck: false, serializableCheck: false }),
+ });
+
+ if (userId) {
+ store.dispatch(loginSuccess({ user_id: userId }));
+ }
+
+ return store;
+};
+
+const renderSession = ({ token, userId }: { token?: string; userId?: string }) => {
+ if (token) {
+ localStorage.setItem('id_token', token);
+ }
+
+ const store = buildStore({ userId });
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ return renderHook(() => useAuthenticatedSession(), { wrapper });
+};
+
+describe('useAuthenticatedSession', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(cleanup);
+
+ test('a token with an identity is a usable session', () => {
+ const { result } = renderSession({ token: 'a-token', userId: USER_ID });
+
+ expect(result.current).toBe(true);
+ expect(localStorage.getItem('id_token')).toBe('a-token');
+ });
+
+ // `localStorage` is shared between tabs, so removing the token here would reach a tab that is
+ // signed in and using it.
+ test('a token with no identity is not a usable session, and the token is left in place', () => {
+ const { result } = renderSession({ token: 'a-token' });
+
+ expect(result.current).toBe(false);
+ expect(localStorage.getItem('id_token')).toBe('a-token');
+ });
+
+ test('an identity with no token is not a usable session', () => {
+ const { result } = renderSession({ userId: USER_ID });
+
+ expect(result.current).toBe(false);
+ });
+
+ test('a signed-out browser is left alone', () => {
+ const { result } = renderSession({});
+
+ expect(result.current).toBe(false);
+ expect(localStorage.getItem('id_token')).toBe(null);
+ });
+});
diff --git a/packages/webapp/src/util/dashboardTicket.ts b/packages/webapp/src/util/dashboardTicket.ts
new file mode 100644
index 0000000000..940008f0ed
--- /dev/null
+++ b/packages/webapp/src/util/dashboardTicket.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2026 LiteFarm.org
+ * This file is part of LiteFarm.
+ *
+ * LiteFarm is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LiteFarm is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details, see .
+ */
+
+export const DASHBOARD_RETURN_TO_PARAM = 'return_to';
+
+/** Not validated here — the API refuses a ticket for an address off its allowlist */
+export function getReturnToFromSearch(search: string): string | null {
+ return new URLSearchParams(search).get(DASHBOARD_RETURN_TO_PARAM) || null;
+}
+
+export function buildDashboardTicketUrl(returnTo: string, ticket: string): string {
+ const url = new URL(returnTo);
+ url.searchParams.set('ticket', ticket);
+ return url.toString();
+}
diff --git a/packages/webapp/vitest.config.ts b/packages/webapp/vitest.config.ts
index 689565537f..8408fa5d2f 100644
--- a/packages/webapp/vitest.config.ts
+++ b/packages/webapp/vitest.config.ts
@@ -5,7 +5,7 @@ export default mergeConfig(
viteConfig,
defineConfig({
test: {
- include: ['src/tests/**/*.test.js?(x)'],
+ include: ['src/tests/**/*.test.{js,jsx,ts,tsx}'],
environment: 'happy-dom',
},
}),