diff --git a/.github/workflows/automated_tests.yml b/.github/workflows/automated_tests.yml index 0b4252ce1c..adff2292b9 100644 --- a/.github/workflows/automated_tests.yml +++ b/.github/workflows/automated_tests.yml @@ -32,6 +32,7 @@ jobs: JWT_RESET_SECRET: ${{secrets.JWT_RESET_SECRET}} JWT_SCHEDULER_SECRET: ${{secrets.JWT_SCHEDULER_SECRET}} JWT_SECRET: ${{secrets.JWT_SECRET}} + JWT_DASHBOARD_SECRET: Dashboard_tickets_get_their_own_secret TEST_USER: ${{secrets.TEST_USER}} TEST_USER_ID: ${{secrets.TEST_USER_ID}} steps: diff --git a/package-lock.json b/package-lock.json index 444f1d648c..8965f4a121 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4487,9 +4487,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "dev": true, "license": "MIT", "engines": { diff --git a/packages/api/.env.default b/packages/api/.env.default index 1d3f690659..4b55dcebac 100644 --- a/packages/api/.env.default +++ b/packages/api/.env.default @@ -22,6 +22,11 @@ JWT_INVITE_SECRET=Any_arbitrary_string_will_do JWT_RESET_SECRET=Production_is_secured_with_a_long_random_string JWT_FARM_SECRET=Here_we_can_use_friendly_explanations JWT_SCHEDULER_SECRET=Another_token_was_needed_for_the_scheduler +JWT_DASHBOARD_SECRET=Dashboard_tickets_get_their_own_secret + +# Comma-separated exact addresses an Analytics Dashboard ticket may be returned to. +# Contact the Analytics Dashboard team for the value for your environment. +DASHBOARD_ALLOWED_RETURN_TO=? # Create your own (free in most cases) Google API key at https://console.cloud.google.com/apis/dashboard # (Optional) We use google-maps-services-js package so API services on this key can be restricted to APIs listed here: diff --git a/packages/api/db/migration/20260806000000_add_dashboard_ticket_use.js b/packages/api/db/migration/20260806000000_add_dashboard_ticket_use.js new file mode 100644 index 0000000000..05ed256e2c --- /dev/null +++ b/packages/api/db/migration/20260806000000_add_dashboard_ticket_use.js @@ -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 . + */ + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const up = async function (knex) { + await knex.schema.createTable('dashboard_ticket_use', (table) => { + table.text('jti').primary(); + table.timestamp('used_at').notNullable().defaultTo(knex.fn.now()); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = async function (knex) { + await knex.schema.dropTable('dashboard_ticket_use'); +}; diff --git a/packages/api/package-lock.json b/packages/api/package-lock.json index 2a02e1640b..6c7b86f533 100644 --- a/packages/api/package-lock.json +++ b/packages/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "litefarm-api", - "version": "3.13.0", + "version": "3.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "litefarm-api", - "version": "3.13.0", + "version": "3.13.1", "dependencies": { "@aws-sdk/client-s3": "^3.994.0", "@datafoodconsortium/connector": "2.0.0-beta.2", @@ -10399,9 +10399,9 @@ "license": "Apache-2.0" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -12352,9 +12352,9 @@ } }, "node_modules/ip-address": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", - "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/packages/api/package.json b/packages/api/package.json index d9b931e70b..0bf18aab97 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "litefarm-api", - "version": "3.13.0", + "version": "3.13.1", "description": "LiteFarm API server", "main": "./api/src/server.js", "type": "module", diff --git a/packages/api/src/controllers/loginController.js b/packages/api/src/controllers/loginController.js index 260b7ed0a3..f6eba37f1e 100644 --- a/packages/api/src/controllers/loginController.js +++ b/packages/api/src/controllers/loginController.js @@ -24,6 +24,9 @@ import parser from 'ua-parser-js'; import UserLogModel from '../models/userLogModel.js'; import EmailModel from '../models/emailTokenModel.js'; import { createToken } from '../util/jwt.js'; +import { randomUUID } from 'crypto'; +import jwt from 'jsonwebtoken'; +import knex from '../util/knex.js'; const loginController = { authenticateUser() { @@ -39,14 +42,7 @@ const loginController = { const ua = parser(req.headers['user-agent']); const languages = req.acceptsLanguages(); let userID; - - let ip = req.headers['x-forwarded-for']; - if (ip) { - const list = ip.split(','); - ip = list[list.length - 1]; - } else { - ip = req.connection.remoteAddress; - } + const { ip } = req; try { const userData = await UserModel.query().select('*').where('email', email).first(); @@ -242,6 +238,120 @@ const loginController = { } }; }, + + dashboardIssueTicket() { + return async (req, res) => { + try { + const { user_id } = req.auth; + const { return_to, farm_id } = req.body; + + const allowedReturnAddresses = (process.env.DASHBOARD_ALLOWED_RETURN_TO ?? '') + .split(',') + .map((address) => address.trim()) + // Without this, an unset variable produces [''] and return_to: '' is allowed + .filter(Boolean); + + // Exact string match: a prefix, suffix or substring of an allowed address is not a match. + if (!allowedReturnAddresses.includes(return_to)) { + return res.status(400).send({ message: 'return_to is not an allowed address.' }); + } + + if (farm_id) { + const userFarm = await UserFarmModel.query() + .where({ user_id, farm_id, status: 'Active' }) + .first(); + if (!userFarm) { + return res.sendStatus(403); + } + } + + const ticket = await createToken('dashboard', { + user_id, + farm_id: farm_id ?? null, + jti: randomUUID(), + }); + + return res.status(200).send({ ticket, return_to }); + } catch (error) { + console.error(error); + return res.status(500).json({ error }); + } + }; + }, + + dashboardExchange() { + return async (req, res) => { + try { + const { ticket } = req.body; + + if (!ticket) { + return res.status(400).send({ message: 'ticket is required.' }); + } + + let payload; + try { + payload = jwt.verify(ticket, process.env.JWT_DASHBOARD_SECRET, { + algorithms: ['HS256'], + }); + } catch (_verificationError) { + return res.sendStatus(401); + } + + const { jti, user_id, farm_id } = payload; + if (!jti || !user_id) { + return res.sendStatus(401); + } + + const claimSucceeded = await knex.transaction(async (trx) => { + await trx('dashboard_ticket_use') + .whereRaw("used_at < now() - interval '5 minutes'") + .del(); + + const inserted = await trx('dashboard_ticket_use') + .insert({ jti }) + .onConflict('jti') + .ignore() + .returning('jti'); + + // False when this ticket has already been exchanged + return inserted.length > 0; + }); + + if (!claimSucceeded) { + return res.sendStatus(401); + } + + const user = await UserModel.query() + .select('user_id', 'email', 'first_name') + .findById(user_id); + if (!user) { + return res.sendStatus(401); + } + + const farms = await UserFarmModel.query() + .select('userFarm.farm_id', 'farm.farm_name', 'userFarm.role_id') + .join('farm', 'userFarm.farm_id', 'farm.farm_id') + .where('userFarm.user_id', user_id) + .andWhere('userFarm.status', 'Active') + .andWhere('farm.deleted', false); + + if (farm_id && !farms.some((farm) => farm.farm_id === farm_id)) { + return res.sendStatus(401); + } + + return res.status(200).send({ + user_id: user.user_id, + email: user.email, + first_name: user.first_name, + farm_id: farm_id ?? null, + farms, + }); + } catch (error) { + console.error(error); + return res.status(500).json({ error }); + } + }; + }, }; async function sendMissingInvitations(user) { diff --git a/packages/api/src/controllers/userLogController.js b/packages/api/src/controllers/userLogController.js index 5f50174cec..5a959321f4 100644 --- a/packages/api/src/controllers/userLogController.js +++ b/packages/api/src/controllers/userLogController.js @@ -23,7 +23,7 @@ const userLogController = { const { user_id } = req.auth; const { screen_width, screen_height, farm_id } = req.body; try { - const ip = req.headers['x-forwarded-for']?.split(',').shift() || req.socket?.remoteAddress; + const { ip } = req; const ua = parser(req.headers['user-agent']); const languages = req.acceptsLanguages(); await UserLogModel.query().insert({ diff --git a/packages/api/src/routes/loginRoute.js b/packages/api/src/routes/loginRoute.js index 31ea6b47f4..1f1a4b7652 100644 --- a/packages/api/src/routes/loginRoute.js +++ b/packages/api/src/routes/loginRoute.js @@ -2,9 +2,14 @@ import express from 'express'; const router = express.Router(); import loginController from '../controllers/loginController.js'; import checkGoogleJwt from '../middleware/acl/checkGoogleJwt.js'; +import checkJwt from '../middleware/acl/checkJwt.js'; router.post('/google', checkGoogleJwt, loginController.loginWithGoogle()); router.post('/', loginController.authenticateUser()); router.get('/user/:email', loginController.getUserNameByUserEmail()); +// This router is mounted before the global checkJwt in server.ts, +// so the middleware is attached here to make the endpoint require a login token +router.post('/dashboard/ticket', checkJwt, loginController.dashboardIssueTicket()); +router.post('/dashboard/exchange', loginController.dashboardExchange()); export default router; diff --git a/packages/api/src/routes/userFarmRoute.js b/packages/api/src/routes/userFarmRoute.js index 01b0ee9aac..38a6885101 100644 --- a/packages/api/src/routes/userFarmRoute.js +++ b/packages/api/src/routes/userFarmRoute.js @@ -25,8 +25,7 @@ import checkInvitationTokenContent from '../middleware/acl/checkInviteTokenConte import checkUserFarmStatus from '../middleware/acl/checkUserFarmStatus.js'; // Get all userFarms for a specified user -// no permission limits -router.get('/user/:user_id', userFarmController.getUserFarmByUserID()); +router.get('/user/:user_id', isSelf, userFarmController.getUserFarmByUserID()); // Get info on all users (userFarm) at a farm router.get( diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index ccbbe67ead..db5818b910 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -31,7 +31,7 @@ if (process.env.SENTRY_DSN && environment !== 'development') { // Automatically instrument Node.js libraries and frameworks ...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations(), ], - 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 @@ -275,6 +275,10 @@ app .use(express.urlencoded({ extended: true })) .disable('x-powered-by') + // https://expressjs.com/en/guide/behind-proxies/ + // 1: trust exactly one hop (nginx) so a client-supplied X-Forwarded-For entry can't spoof req.ip + .set('trust proxy', 1) + // prevent CORS errors .use(cors()) .use((req, res, next) => { diff --git a/packages/api/src/util/jwt.js b/packages/api/src/util/jwt.js index 48feb1d85a..2cb1a49d50 100644 --- a/packages/api/src/util/jwt.js +++ b/packages/api/src/util/jwt.js @@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken'; const ACCESS_TOKEN_EXPIRES_IN = '7d'; const RESET_PASSWORD_TOKEN_EXPIRES_IN = '1d'; const SCHEDULER_TOKEN_EXPIRES_IN = '1d'; +const DASHBOARD_TICKET_EXPIRES_IN = '30s'; const tokenType = { access: process.env.JWT_SECRET, @@ -10,6 +11,7 @@ const tokenType = { passwordReset: process.env.JWT_RESET_SECRET, farm: process.env.JWT_FARM_SECRET, scheduler: process.env.JWT_SCHEDULER_SECRET, + dashboard: process.env.JWT_DASHBOARD_SECRET, }; const expireTime = { access: ACCESS_TOKEN_EXPIRES_IN, @@ -17,6 +19,7 @@ const expireTime = { passwordReset: RESET_PASSWORD_TOKEN_EXPIRES_IN, farm: ACCESS_TOKEN_EXPIRES_IN, scheduler: SCHEDULER_TOKEN_EXPIRES_IN, + dashboard: DASHBOARD_TICKET_EXPIRES_IN, }; function createToken(type, payload) { diff --git a/packages/api/tests/dashboardExchange.test.ts b/packages/api/tests/dashboardExchange.test.ts new file mode 100644 index 0000000000..a0272d3f59 --- /dev/null +++ b/packages/api/tests/dashboardExchange.test.ts @@ -0,0 +1,284 @@ +/* + * 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 chai from 'chai'; + +import chaiHttp from 'chai-http'; +chai.use(chaiHttp); + +import jwt from 'jsonwebtoken'; +import { randomUUID } from 'crypto'; +import { Response } from 'superagent'; +import server from '../src/server.js'; +import knex from '../src/util/knex.js'; +import { tableCleanup } from './testEnvironment.js'; +import mocks from './mock.factories.js'; +import { createToken } from '../src/util/jwt.js'; +import { Farm, User, UserFarm } from '../src/models/types.js'; + +jest.mock('jsdom'); +jest.mock('../src/jobs/station_sync/mapping.js'); +jest.mock('../src/templates/sendEmailTemplate.js', () => ({ + sendEmail: jest.fn(), + emails: { INVITATION: { path: 'invitation_to_farm_email' } }, +})); + +interface ExchangeResponseBody { + user_id?: User['user_id']; + email?: User['email']; + first_name?: User['first_name']; + farm_id?: Farm['farm_id'] | null; + farms?: { + farm_id: Farm['farm_id']; + farm_name: Farm['farm_name']; + role_id: UserFarm['role_id']; + }[]; + message?: string; + ticket?: string; + id_token?: string; + token?: string; + access_token?: string; +} + +type ExchangeResponse = Omit & { body: ExchangeResponseBody }; + +function postRequest(body: Record): Promise { + return chai + .request(server) + .post('/login/dashboard/exchange') + .set('content-type', 'application/json') + .send(body) as unknown as Promise; +} + +function mintTicket({ + user_id, + farm_id = null, +}: { + user_id: User['user_id']; + farm_id?: Farm['farm_id'] | null; +}) { + return createToken('dashboard', { user_id, farm_id, jti: randomUUID() }); +} + +describe('POST /login/dashboard/exchange', () => { + let user: User; + let activeFarm: Farm; + let secondActiveFarm: Farm; + let invitedFarm: Farm; + let deletedFarm: Farm; + + beforeAll(async () => { + [user] = await mocks.usersFactory(); + + [activeFarm] = await mocks.farmFactory(); + [secondActiveFarm] = await mocks.farmFactory(); + [invitedFarm] = await mocks.farmFactory(); + [deletedFarm] = await mocks.farmFactory(); + + for (const farm of [activeFarm, secondActiveFarm, deletedFarm]) { + await mocks.userFarmFactory({ + promisedUser: Promise.resolve([user]), + promisedFarm: Promise.resolve([farm]), + }); + } + await mocks.userFarmFactory( + { + promisedUser: Promise.resolve([user]), + promisedFarm: Promise.resolve([invitedFarm]), + }, + mocks.fakeUserFarm({ status: 'Invited' }), + ); + + await knex('farm').update({ deleted: true }).where({ farm_id: deletedFarm.farm_id }); + }); + + afterAll(async () => { + await tableCleanup(knex); + await knex.destroy(); + }); + + describe('Successful exchange', () => { + test('returns the user and the live farm list for a ticket naming no farm', async () => { + const res = await postRequest({ ticket: await mintTicket({ user_id: user.user_id }) }); + + expect(res.status).toBe(200); + expect(res.body.user_id).toBe(user.user_id); + expect(res.body.email).toBe(user.email); + expect(res.body.first_name).toBe(user.first_name); + expect(res.body.farm_id).toBe(null); + expect(res.body.farms).toEqual( + expect.arrayContaining([ + { + farm_id: activeFarm.farm_id, + farm_name: activeFarm.farm_name, + role_id: expect.any(Number), + }, + { + farm_id: secondActiveFarm.farm_id, + farm_name: secondActiveFarm.farm_name, + role_id: expect.any(Number), + }, + ]), + ); + expect(res.body.farms).toHaveLength(2); + }); + + test('returns the farm the ticket names when the membership is Active', async () => { + const res = await postRequest({ + ticket: await mintTicket({ user_id: user.user_id, farm_id: activeFarm.farm_id }), + }); + + expect(res.status).toBe(200); + expect(res.body.farm_id).toBe(activeFarm.farm_id); + }); + + test('returns no token of any kind', async () => { + const res = await postRequest({ ticket: await mintTicket({ user_id: user.user_id }) }); + + expect(res.status).toBe(200); + expect(res.body.ticket).toBeUndefined(); + expect(res.body.id_token).toBeUndefined(); + expect(res.body.token).toBeUndefined(); + expect(res.body.access_token).toBeUndefined(); + }); + + test('is reachable with no Authorization header', async () => { + const res = await postRequest({ ticket: await mintTicket({ user_id: user.user_id }) }); + + expect(res.status).toBe(200); + }); + }); + + describe('Single use', () => { + test('returns 401 when the same ticket is exchanged a second time', async () => { + const ticket = await mintTicket({ user_id: user.user_id }); + + const first = await postRequest({ ticket }); + const second = await postRequest({ ticket }); + + expect(first.status).toBe(200); + expect(second.status).toBe(401); + }); + + test('lets exactly one of two concurrent exchanges succeed', async () => { + const ticket = await mintTicket({ user_id: user.user_id }); + + const [first, second] = await Promise.all([postRequest({ ticket }), postRequest({ ticket })]); + + expect([first.status, second.status].sort()).toEqual([200, 401]); + }); + }); + + describe('Rejected tickets', () => { + test('returns 401 for a ticket signed with JWT_SECRET', async () => { + const ticket = await createToken('access', { + user_id: user.user_id, + farm_id: null, + jti: randomUUID(), + }); + + const res = await postRequest({ ticket }); + + expect(res.status).toBe(401); + }); + + test('returns 401 for an expired ticket', async () => { + const ticket = jwt.sign( + { user_id: user.user_id, farm_id: null, jti: randomUUID() }, + process.env.JWT_DASHBOARD_SECRET as string, + { algorithm: 'HS256', expiresIn: '-1s' }, + ); + + const res = await postRequest({ ticket }); + + expect(res.status).toBe(401); + }); + + test('returns 400 when the body has no ticket', async () => { + const res = await postRequest({}); + + expect(res.status).toBe(400); + }); + + test('returns 401 for a validly signed ticket with no jti', async () => { + const ticket = await createToken('dashboard', { user_id: user.user_id, farm_id: null }); + + const res = await postRequest({ ticket }); + + expect(res.status).toBe(401); + }); + + test('returns 401 for a ticket naming a user that does not exist', async () => { + const res = await postRequest({ ticket: await mintTicket({ user_id: randomUUID() }) }); + + expect(res.status).toBe(401); + }); + }); + + describe('Live membership', () => { + let revokedUser: User; + let revokedFarm: Farm; + let keptFarm: Farm; + + beforeAll(async () => { + [revokedUser] = await mocks.usersFactory(); + [revokedFarm] = await mocks.farmFactory(); + [keptFarm] = await mocks.farmFactory(); + + for (const farm of [revokedFarm, keptFarm]) { + await mocks.userFarmFactory({ + promisedUser: Promise.resolve([revokedUser]), + promisedFarm: Promise.resolve([farm]), + }); + } + + await knex('userFarm') + .update({ status: 'Inactive' }) + .where({ user_id: revokedUser.user_id, farm_id: revokedFarm.farm_id }); + }); + + test('returns 401 when the ticket names a farm the user is no longer Active on', async () => { + const res = await postRequest({ + ticket: await mintTicket({ + user_id: revokedUser.user_id, + farm_id: revokedFarm.farm_id, + }), + }); + + expect(res.status).toBe(401); + }); + + test('omits a no-longer-Active farm from farms when the ticket names no farm', async () => { + const res = await postRequest({ ticket: await mintTicket({ user_id: revokedUser.user_id }) }); + + expect(res.status).toBe(200); + expect(res.body.farms?.map(({ farm_id }) => farm_id)).toEqual([keptFarm.farm_id]); + }); + + test('omits a farm the user is only Invited to', async () => { + const res = await postRequest({ ticket: await mintTicket({ user_id: user.user_id }) }); + + expect(res.status).toBe(200); + expect(res.body.farms?.map(({ farm_id }) => farm_id)).not.toContain(invitedFarm.farm_id); + }); + + test('omits a soft-deleted farm the user is an Active member of', async () => { + const res = await postRequest({ ticket: await mintTicket({ user_id: user.user_id }) }); + + expect(res.status).toBe(200); + expect(res.body.farms?.map(({ farm_id }) => farm_id)).not.toContain(deletedFarm.farm_id); + }); + }); +}); diff --git a/packages/api/tests/dashboardTicket.test.ts b/packages/api/tests/dashboardTicket.test.ts new file mode 100644 index 0000000000..64ffae2931 --- /dev/null +++ b/packages/api/tests/dashboardTicket.test.ts @@ -0,0 +1,251 @@ +/* + * 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 chai from 'chai'; + +import chaiHttp from 'chai-http'; +chai.use(chaiHttp); + +import jwt from 'jsonwebtoken'; +import { Response } from 'superagent'; +import server from '../src/server.js'; +import knex from '../src/util/knex.js'; +import { tableCleanup } from './testEnvironment.js'; +import mocks from './mock.factories.js'; +import { createToken } from '../src/util/jwt.js'; +import { Farm, User } from '../src/models/types.js'; + +jest.mock('jsdom'); +jest.mock('../src/jobs/station_sync/mapping.js'); +jest.mock('../src/templates/sendEmailTemplate.js', () => ({ + sendEmail: jest.fn(), + emails: { INVITATION: { path: 'invitation_to_farm_email' } }, +})); + +const ALLOWED_RETURN_ADDRESSES = [ + 'https://dashboard.test/auth/finish', + 'https://second-host.test/litefarm/auth/finish', +]; +const [ALLOWED_RETURN_TO] = ALLOWED_RETURN_ADDRESSES; + +interface TicketResponseBody { + ticket: string; + return_to: string; + message?: string; +} + +interface DashboardTicket extends jwt.JwtPayload { + user_id: User['user_id']; + farm_id: Farm['farm_id'] | null; + jti: string; + iat: number; + exp: number; +} + +type TicketResponse = Omit & { body: TicketResponseBody }; + +function postRequest( + body: Record, + { authorization }: { authorization?: string } = {}, +): Promise { + const request = chai + .request(server) + .post('/login/dashboard/ticket') + .set('content-type', 'application/json'); + + if (authorization) { + request.set('Authorization', authorization); + } + + return request.send(body) as unknown as Promise; +} + +function decodeTicket(ticket: string): DashboardTicket { + return jwt.verify(ticket, process.env.JWT_DASHBOARD_SECRET as string) as DashboardTicket; +} + +describe('POST /login/dashboard/ticket', () => { + let user: User; + let otherUser: User; + let activeFarm: Farm; + let strangerFarm: Farm; + let inactiveFarm: Farm; + let invitedFarm: Farm; + let authorization: string; + + beforeAll(async () => { + [user] = await mocks.usersFactory(); + [otherUser] = await mocks.usersFactory(); + + [activeFarm] = await mocks.farmFactory(); + [strangerFarm] = await mocks.farmFactory(); + [inactiveFarm] = await mocks.farmFactory(); + [invitedFarm] = await mocks.farmFactory(); + + await mocks.userFarmFactory({ + promisedUser: Promise.resolve([user]), + promisedFarm: Promise.resolve([activeFarm]), + }); + await mocks.userFarmFactory( + { + promisedUser: Promise.resolve([user]), + promisedFarm: Promise.resolve([inactiveFarm]), + }, + mocks.fakeUserFarm({ status: 'Inactive' }), + ); + await mocks.userFarmFactory( + { + promisedUser: Promise.resolve([user]), + promisedFarm: Promise.resolve([invitedFarm]), + }, + mocks.fakeUserFarm({ status: 'Invited' }), + ); + + authorization = `Bearer ${await createToken('access', { user_id: user.user_id })}`; + }); + + beforeEach(() => { + process.env.DASHBOARD_ALLOWED_RETURN_TO = ALLOWED_RETURN_ADDRESSES.join(','); + }); + + afterAll(async () => { + await tableCleanup(knex); + await knex.destroy(); + }); + + describe('Identity', () => { + test('names the token holder, not a user_id supplied in the body', async () => { + const res = await postRequest( + { return_to: ALLOWED_RETURN_TO, user_id: otherUser.user_id }, + { authorization }, + ); + + expect(res.status).toBe(200); + expect(decodeTicket(res.body.ticket).user_id).toBe(user.user_id); + }); + + test('returns 401 without an Authorization header', async () => { + const res = await postRequest({ return_to: ALLOWED_RETURN_TO }); + + expect(res.status).toBe(401); + expect(res.body.ticket).toBeUndefined(); + }); + }); + + describe('Farm membership', () => { + test('issues a ticket for a farm the user is an Active member of', async () => { + const res = await postRequest( + { return_to: ALLOWED_RETURN_TO, farm_id: activeFarm.farm_id }, + { authorization }, + ); + + expect(res.status).toBe(200); + expect(decodeTicket(res.body.ticket).farm_id).toBe(activeFarm.farm_id); + }); + + test('issues a ticket with a null farm_id when the body omits farm_id', async () => { + const res = await postRequest({ return_to: ALLOWED_RETURN_TO }, { authorization }); + + expect(res.status).toBe(200); + expect(decodeTicket(res.body.ticket).farm_id).toBe(null); + }); + + test.each([ + ['no userFarm row at all', () => strangerFarm], + ['an Inactive userFarm row', () => inactiveFarm], + ['an Invited userFarm row', () => invitedFarm], + ])('returns 403 for a farm with %s', async (_label, getFarm) => { + const res = await postRequest( + { return_to: ALLOWED_RETURN_TO, farm_id: getFarm().farm_id }, + { authorization }, + ); + + expect(res.status).toBe(403); + expect(res.body.ticket).toBeUndefined(); + }); + }); + + describe('Return address allowlist', () => { + test.each(ALLOWED_RETURN_ADDRESSES)('issues a ticket for %s', async (return_to) => { + const res = await postRequest({ return_to }, { authorization }); + + expect(res.status).toBe(200); + expect(res.body.return_to).toBe(return_to); + }); + + test.each([ + ['absent', undefined], + ['an unrelated address', 'https://attacker.example'], + ['a prefix of an allowed address', ALLOWED_RETURN_TO.slice(0, -3)], + ['a suffix of an allowed address', new URL(ALLOWED_RETURN_TO).pathname], + ['a substring of an allowed address', ALLOWED_RETURN_TO.replace('https://', '')], + ['an allowed address with an appended segment', `${ALLOWED_RETURN_TO}.attacker.example`], + ])('returns 400 when return_to is %s', async (_label, return_to) => { + const res = await postRequest({ return_to }, { authorization }); + + expect(res.status).toBe(400); + expect(res.body.ticket).toBeUndefined(); + }); + + test.each([ + ['unset', undefined], + ['blank', ''], + ['a lone comma', ','], + ])( + 'rejects an empty return_to when DASHBOARD_ALLOWED_RETURN_TO is %s', + async (_label, allowlist) => { + if (allowlist === undefined) { + delete process.env.DASHBOARD_ALLOWED_RETURN_TO; + } else { + process.env.DASHBOARD_ALLOWED_RETURN_TO = allowlist; + } + + const res = await postRequest({ return_to: '' }, { authorization }); + + expect(res.status).toBe(400); + expect(res.body.ticket).toBeUndefined(); + }, + ); + }); + + describe('Ticket properties', () => { + test('expires 30 seconds after it is issued', async () => { + const res = await postRequest({ return_to: ALLOWED_RETURN_TO }, { authorization }); + const { iat, exp } = decodeTicket(res.body.ticket); + + expect(exp - iat).toBe(30); + }); + + test('does not verify against JWT_SECRET', async () => { + const res = await postRequest({ return_to: ALLOWED_RETURN_TO }, { authorization }); + + expect(() => jwt.verify(res.body.ticket, process.env.JWT_SECRET as string)).toThrow(); + }); + + test('carries a jti that differs between two tickets', async () => { + const [first, second] = await Promise.all([ + postRequest({ return_to: ALLOWED_RETURN_TO }, { authorization }), + postRequest({ return_to: ALLOWED_RETURN_TO }, { authorization }), + ]); + + const firstJti = decodeTicket(first.body.ticket).jti; + const secondJti = decodeTicket(second.body.ticket).jti; + + expect(firstJti).toBeTruthy(); + expect(secondJti).toBeTruthy(); + expect(firstJti).not.toBe(secondJti); + }); + }); +}); diff --git a/packages/api/tests/userFarm.test.js b/packages/api/tests/userFarm.test.js index d77f004454..9e8833158b 100644 --- a/packages/api/tests/userFarm.test.js +++ b/packages/api/tests/userFarm.test.js @@ -40,8 +40,13 @@ import userModel from '../src/models/userModel.js'; describe('User Farm Tests', () => { // let middleware; - function getUserFarmsOfUserRequest({ user_id }, callback) { - chai.request(server).get(`/user_farm/user/${user_id}`).end(callback); + // The mocked checkJwt above reads the authenticated user from the user_id header, so that header + // is what stands in for the caller's token + function getUserFarmsOfUserRequest({ user_id, requesting_user_id = user_id }) { + return chai + .request(server) + .get(`/user_farm/user/${user_id}`) + .set('user_id', requesting_user_id); } // note: the object that is sent should be adjusted to not include consent_version @@ -217,11 +222,22 @@ describe('User Farm Tests', () => { await createUserFarmForUser({}, user); await createUserFarmForUser({}, user); - getUserFarmsOfUserRequest({ user_id: user.user_id }, async (_err, res) => { - expect(_err).toEqual(null); - expect(res.status).toBe(200); - expect(res.body.length).toBe(3); + const res = await getUserFarmsOfUserRequest({ user_id: user.user_id }); + + expect(res.status).toBe(200); + expect(res.body.length).toBe(3); + }); + + test('Get all user farms of another user is forbidden', async () => { + const { user } = await setupUserFarm({}); + const { user: otherUser } = await setupUserFarm({}); + + const res = await getUserFarmsOfUserRequest({ + user_id: user.user_id, + requesting_user_id: otherUser.user_id, }); + + expect(res.status).toBe(403); }); test('Update consent status for user farm', async () => { diff --git a/packages/webapp/nginx.conf b/packages/webapp/nginx.conf index 7d19ff490a..8e829a9ba3 100644 --- a/packages/webapp/nginx.conf +++ b/packages/webapp/nginx.conf @@ -34,10 +34,19 @@ http { gzip on; gzip_vary on; - gzip_min_length 10240; + gzip_min_length 1024; gzip_comp_level 6; - gzip_proxied expired no-cache no-store private auth; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml; + gzip_proxied any; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/x-javascript + application/xml + application/javascript + application/json + image/svg+xml; # include /etc/nginx/conf.d/*.conf; @@ -90,11 +99,12 @@ http { location / { limit_req zone=one burst=5 nodelay; - - proxy_set_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE'; + proxy_pass_request_headers on; - proxy_set_header 'Access-Control-Allow-Origin' 'https://beta.litefarm.org'; proxy_pass http://backend:5000/; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; } listen 443 ssl; # managed by Certbot ssl_certificate /etc/letsencrypt/live/beta.litefarm.org/fullchain.pem; # managed by Certbot diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 01b46eedd8..77ee0e611d 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -1,6 +1,6 @@ { "name": "litefarm-webapp", - "version": "3.13.0", + "version": "3.13.1", "description": "LiteFarm Web application", "type": "module", "devEngines": { @@ -124,6 +124,7 @@ "@storybook/react-vite": "^8.2.8", "@storybook/test": "^8.2.8", "@storybook/test-runner": "^0.19.1", + "@testing-library/react": "^16.3.2", "@types/d3": "^7.4.0", "@types/google.maps": "^3.58.1", "@types/history": "4", diff --git a/packages/webapp/pnpm-lock.yaml b/packages/webapp/pnpm-lock.yaml index 00143f07a3..2d11a488a9 100644 --- a/packages/webapp/pnpm-lock.yaml +++ b/packages/webapp/pnpm-lock.yaml @@ -289,6 +289,9 @@ importers: '@storybook/test-runner': specifier: ^0.19.1 version: 0.19.1(@types/node@24.10.1)(babel-plugin-macros@3.1.0)(storybook@8.6.14(prettier@3.6.2)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/d3': specifier: ^7.4.0 version: 7.4.3 @@ -2390,6 +2393,21 @@ packages: resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@testing-library/user-event@14.5.2': resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} engines: {node: '>=12', npm: '>=6'} @@ -9849,6 +9867,16 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.0)(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.26 + '@types/react-dom': 18.3.7(@types/react@18.3.26) + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: '@testing-library/dom': 10.4.0 diff --git a/packages/webapp/public/locales/en/message.json b/packages/webapp/public/locales/en/message.json index 8987fff98c..84fca30791 100644 --- a/packages/webapp/public/locales/en/message.json +++ b/packages/webapp/public/locales/en/message.json @@ -148,6 +148,7 @@ }, "LOGIN": { "ERROR": { + "DASHBOARD_TICKET": "Could not connect to the Data Dashboard. Please try again.", "LOGIN_FAIL": "Failed to login user info" } }, diff --git a/packages/webapp/public/locales/en/translation.json b/packages/webapp/public/locales/en/translation.json index 01d4afe76b..338d272388 100644 --- a/packages/webapp/public/locales/en/translation.json +++ b/packages/webapp/public/locales/en/translation.json @@ -2117,6 +2117,8 @@ }, "SIGNUP": { "CHANGES": "Read about the changes", + "DASHBOARD_CONTINUE_TO": "Continue to the Data Dashboard", + "DASHBOARD_SIGN_IN_WITH": "Sign in with your LiteFarm account", "EMAIL_INVALID": "Email is invalid", "ENTER_EMAIL": "Enter your email address", "EXPIRED_ERROR": "We've updated our infrastructure and you'll need to reset your password. Check your inbox to proceed.", diff --git a/packages/webapp/public/locales/es/message.json b/packages/webapp/public/locales/es/message.json index c0a5529ac3..fa8a1db79e 100644 --- a/packages/webapp/public/locales/es/message.json +++ b/packages/webapp/public/locales/es/message.json @@ -148,6 +148,7 @@ }, "LOGIN": { "ERROR": { + "DASHBOARD_TICKET": "No se pudo conectar al panel de datos. Por favor, inténtalo de nuevo.", "LOGIN_FAIL": "No se pudo iniciar sesión con información del usuario" } }, diff --git a/packages/webapp/public/locales/es/translation.json b/packages/webapp/public/locales/es/translation.json index 64a95ce67d..7962d67fec 100644 --- a/packages/webapp/public/locales/es/translation.json +++ b/packages/webapp/public/locales/es/translation.json @@ -2117,6 +2117,8 @@ }, "SIGNUP": { "CHANGES": "Leer sobre los cambios", + "DASHBOARD_CONTINUE_TO": "Continuar al panel de datos", + "DASHBOARD_SIGN_IN_WITH": "Inicia sesión con tu cuenta de LiteFarm", "EMAIL_INVALID": "Correo invalido", "ENTER_EMAIL": "Ingresar Correo", "EXPIRED_ERROR": "Hemos actualizado nuestra infraestructura y deberá restablecer su contraseña. Revise su bandeja de entrada para continuar.", diff --git a/packages/webapp/public/locales/fr/message.json b/packages/webapp/public/locales/fr/message.json index 89249d516c..7e602ae6c7 100644 --- a/packages/webapp/public/locales/fr/message.json +++ b/packages/webapp/public/locales/fr/message.json @@ -148,6 +148,7 @@ }, "LOGIN": { "ERROR": { + "DASHBOARD_TICKET": "Impossible de se connecter au tableau de bord des données. Veuillez réessayer.", "LOGIN_FAIL": "Impossible de se connecter aux informations de l'utilisateur" } }, diff --git a/packages/webapp/public/locales/fr/translation.json b/packages/webapp/public/locales/fr/translation.json index 4cd8087680..a95c91b50b 100644 --- a/packages/webapp/public/locales/fr/translation.json +++ b/packages/webapp/public/locales/fr/translation.json @@ -2117,6 +2117,8 @@ }, "SIGNUP": { "CHANGES": "En savoir plus sur les changements", + "DASHBOARD_CONTINUE_TO": "Continuer vers le tableau de bord des données", + "DASHBOARD_SIGN_IN_WITH": "Connectez-vous avec votre compte LiteFarm", "EMAIL_INVALID": "Courriel non-valide", "ENTER_EMAIL": "Entrez votre Courriel", "EXPIRED_ERROR": "Nous avons mis à jour notre infrastructure et vous devriez réinitialiser votre mot de passe. Vérifiez votre boîte de courriels pour continuer.", diff --git a/packages/webapp/public/locales/hi/message.json b/packages/webapp/public/locales/hi/message.json index 0ddce667a4..d682137240 100644 --- a/packages/webapp/public/locales/hi/message.json +++ b/packages/webapp/public/locales/hi/message.json @@ -148,6 +148,7 @@ }, "LOGIN": { "ERROR": { + "DASHBOARD_TICKET": "डेटा डैशबोर्ड से कनेक्ट नहीं हो सका। कृपया पुनः प्रयास करें।", "LOGIN_FAIL": "उपयोगकर्ता जानकारी में लॉगिन करने में विफल" } }, diff --git a/packages/webapp/public/locales/hi/translation.json b/packages/webapp/public/locales/hi/translation.json index 8928343bd8..36c93b71aa 100644 --- a/packages/webapp/public/locales/hi/translation.json +++ b/packages/webapp/public/locales/hi/translation.json @@ -2117,6 +2117,8 @@ }, "SIGNUP": { "CHANGES": "परिवर्तनों के बारे में पढ़ें", + "DASHBOARD_CONTINUE_TO": "डेटा डैशबोर्ड पर आगे बढ़ें", + "DASHBOARD_SIGN_IN_WITH": "अपने LiteFarm खाते से साइन इन करें", "EMAIL_INVALID": "ईमेल अमान्य है", "ENTER_EMAIL": "अपना ईमेल पता दर्ज करें", "EXPIRED_ERROR": "हमने अपनी अवसंरचना को अपडेट किया है, और आपको अपना पासवर्ड रीसेट करने की आवश्यकता होगी। कृपया अपने इनबॉक्स की जाँच करें।", diff --git a/packages/webapp/public/locales/pt/message.json b/packages/webapp/public/locales/pt/message.json index 919aedc427..aff793b83e 100644 --- a/packages/webapp/public/locales/pt/message.json +++ b/packages/webapp/public/locales/pt/message.json @@ -148,6 +148,7 @@ }, "LOGIN": { "ERROR": { + "DASHBOARD_TICKET": "Não foi possível conectar ao painel de dados. Por favor, tente novamente.", "LOGIN_FAIL": "Falha no login das informações do usuário" } }, diff --git a/packages/webapp/public/locales/pt/translation.json b/packages/webapp/public/locales/pt/translation.json index 4aebe74ca3..2bee426550 100644 --- a/packages/webapp/public/locales/pt/translation.json +++ b/packages/webapp/public/locales/pt/translation.json @@ -2117,6 +2117,8 @@ }, "SIGNUP": { "CHANGES": "Leia sobre as mudanças", + "DASHBOARD_CONTINUE_TO": "Continuar para o painel de dados", + "DASHBOARD_SIGN_IN_WITH": "Faça login com a sua conta LiteFarm", "EMAIL_INVALID": "Email é inválido", "ENTER_EMAIL": "Digite seu endereço de e-mail", "EXPIRED_ERROR": "NÃO EXISTE", diff --git a/packages/webapp/src/components/CustomSignUp/index.jsx b/packages/webapp/src/components/CustomSignUp/index.jsx index 2195a76412..a9b5f8c817 100644 --- a/packages/webapp/src/components/CustomSignUp/index.jsx +++ b/packages/webapp/src/components/CustomSignUp/index.jsx @@ -24,6 +24,7 @@ export default function PureCustomSignUp({ classes = {}, isChrome = true, errorMessage = undefined, + isDashboardSignIn = false, }) { const { t } = useTranslation(); const wrongBrowserTop = t('SIGNUP.WRONG_BROWSER'); @@ -33,7 +34,17 @@ export default function PureCustomSignUp({
- + {isDashboardSignIn && ( +
+
{t('SIGNUP.DASHBOARD_CONTINUE_TO')}
+
+ {t('SIGNUP.DASHBOARD_SIGN_IN_WITH')} +
+
+ )} + {!isDashboardSignIn && ( + + )} {(!isChrome || !!errorMessage) && (
{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', }, }),