diff --git a/db/migrations/20251204204114_add_registration_questions.ts b/db/migrations/20251204204114_add_registration_questions.ts index 6b2331a2..fbc29df0 100644 --- a/db/migrations/20251204204114_add_registration_questions.ts +++ b/db/migrations/20251204204114_add_registration_questions.ts @@ -7,7 +7,7 @@ export async function up(knex: Knex): Promise { table.string("zip_code").nullable(); table.integer("travel_cost").nullable(); table.string("travel_method").nullable(); - table.string("travel_additional").nullable(); + table.text("travel_additional").nullable(); }); } diff --git a/openapi.json b/openapi.json index 5dba3904..e5201314 100644 --- a/openapi.json +++ b/openapi.json @@ -13315,10 +13315,15 @@ "properties": { "email": { "type": "string" + }, + "userIdSuffix": { + "type": "string", + "description": "Last 4 characters of the user's ID, used to confirm identity" } }, "required": [ - "email" + "email", + "userIdSuffix" ] }, "UploadPhotoBody": { diff --git a/package.json b/package.json index 74404342..11c71c03 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "sanitize-html": "^2.14.0", "sharp": "^0.34.4", "url": "^0.11.4", - "uuid": "^13.0.0", + "uuid": "^14.0.0", "webpack": "^5.97.1" }, "devDependencies": { @@ -95,7 +95,7 @@ "eslint": "^9.17.0", "eslint-config-prettier": "^10.0.0", "eslint-plugin-prettier": "^5.2.1", - "globals": "^16.0.0", + "globals": "^17.0.0", "jest": "30.4.2", "prettier": "^3.4.2", "rimraf": "^6.0.1", diff --git a/packages/api-client/src/generated/model/addUserByEmailEntity.ts b/packages/api-client/src/generated/model/addUserByEmailEntity.ts index eab5db03..86763b20 100644 --- a/packages/api-client/src/generated/model/addUserByEmailEntity.ts +++ b/packages/api-client/src/generated/model/addUserByEmailEntity.ts @@ -8,4 +8,6 @@ export interface AddUserByEmailEntity { email: string; + /** Last 4 characters of the user's ID, used to confirm identity */ + userIdSuffix: string; } diff --git a/packages/react-sdk/src/context/FirebaseProvider.tsx b/packages/react-sdk/src/context/FirebaseProvider.tsx index ae069a1c..370ff6d3 100644 --- a/packages/react-sdk/src/context/FirebaseProvider.tsx +++ b/packages/react-sdk/src/context/FirebaseProvider.tsx @@ -65,6 +65,9 @@ export const FirebaseProvider: FC = ({ children, config: rawConfig }) => getToken: () => (auth.currentUser ? auth.currentUser.getIdToken() : null), onUnauthorized: () => { if (typeof window === "undefined" || isLoggingOut.current) return; + // Only a signed-in user's 401 means the session expired. A signed-out + // visitor on a public page should just see the request fail. + if (!auth.currentUser) return; const returnTo = encodeURIComponent(window.location.href); window.location.href = `${config.authServiceUrl}/login?returnTo=${returnTo}`; }, @@ -84,7 +87,7 @@ export const FirebaseProvider: FC = ({ children, config: rawConfig }) => [config.posthog], ); - const verifySession = useCallback(async () => { + const checkSession = useCallback(async (redirectOnUnauthorized: boolean) => { if (isLoggingOut.current) return; // On localhost and Vercel previews the auth cookie is unreadable, so the @@ -100,7 +103,9 @@ export const FirebaseProvider: FC = ({ children, config: rawConfig }) => }); if (response.status === 401) { - if (typeof window !== "undefined") { + setUser(null); + setToken(undefined); + if (redirectOnUnauthorized && typeof window !== "undefined") { const returnTo = encodeURIComponent(window.location.href); window.location.href = `${config.authServiceUrl}/login?returnTo=${returnTo}`; } @@ -131,6 +136,8 @@ export const FirebaseProvider: FC = ({ children, config: rawConfig }) => } }, [auth, config.authServiceUrl, identify]); + const verifySession = useCallback(() => checkSession(true), [checkSession]); + useEffect(() => { if (hasInitialized || isLoggingOut.current) return; @@ -140,7 +147,7 @@ export const FirebaseProvider: FC = ({ children, config: rawConfig }) => setIsLoading(true); try { await Promise.race([ - verifySession(), + checkSession(false), new Promise((_, reject) => setTimeout(() => reject(new Error("Session check timeout")), 5000), ), @@ -159,7 +166,7 @@ export const FirebaseProvider: FC = ({ children, config: rawConfig }) => return () => { cancelled = true; }; - }, [verifySession, hasInitialized]); + }, [checkSession, hasInitialized]); const logout = useCallback(async () => { isLoggingOut.current = true; diff --git a/src/modules/team/team.controller.ts b/src/modules/team/team.controller.ts index cf6a731b..964fcfbb 100644 --- a/src/modules/team/team.controller.ts +++ b/src/modules/team/team.controller.ts @@ -23,7 +23,7 @@ import { ApiProperty, ApiTags, OmitType, PartialType } from "@nestjs/swagger"; import { Role, Roles } from "common/gcp"; import { ApiDoc } from "common/docs"; import { DBExceptionFilter } from "common/filters"; -import { IsEmail, IsOptional, IsString } from "class-validator"; +import { IsEmail, IsOptional, IsString, Length } from "class-validator"; import { Transform } from "class-transformer"; import { nanoid } from "nanoid"; @@ -64,6 +64,13 @@ class AddUserByEmailEntity { @ApiProperty() @IsEmail() email: string; + + @ApiProperty({ + description: "Last 4 characters of the user's ID, used to confirm identity", + }) + @IsString() + @Length(4, 4) + userIdSuffix: string; } class ActiveTeamsParams { @@ -444,8 +451,15 @@ export class TeamController { .where("email", data.email) .first(); - if (!user) { - throw new NotFoundException(`User with email ${data.email} not found`); + // Use the same error for a wrong ID suffix so the endpoint can't be used + // to confirm which emails belong to registered users. + if ( + !user || + user.id.slice(-4).toLowerCase() !== data.userIdSuffix.toLowerCase() + ) { + throw new BadRequestException( + "No user found with that email and user ID combination", + ); } // Check if user is already in this team diff --git a/yarn.lock b/yarn.lock index 40ec3ae0..354fe2d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3043,9 +3043,9 @@ aws-ssl-profiles@^1.1.2: integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== axios@^1.12.0, axios@^1.7.9: - version "1.19.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.19.0.tgz#ddf864d4c8233c0e6873746ab59361537d05ad39" - integrity sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw== + version "1.20.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.20.0.tgz#515513445aa60e71d04b6521ca6210829ccb4786" + integrity sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg== dependencies: follow-redirects "^1.16.0" form-data "^4.0.6" @@ -4622,9 +4622,9 @@ flatted@^3.2.9: integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== follow-redirects@^1.16.0: - version "1.16.0" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" - integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== + version "1.16.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.1.tgz#6e0efa40a727fb369b98dab15883ec34787d675e" + integrity sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg== fontkit@^2.0.4: version "2.0.4" @@ -4951,10 +4951,10 @@ globals@^14.0.0: resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globals@^16.0.0: - version "16.5.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-16.5.0.tgz#ccf1594a437b97653b2be13ed4d8f5c9f850cac1" - integrity sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ== +globals@^17.0.0: + version "17.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-17.12.0.tgz#910a368b8f093d3feb3f77a56dcefa05647d698e" + integrity sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA== google-auth-library@^10.1.0, google-auth-library@^10.2.0: version "10.2.1" @@ -8570,10 +8570,10 @@ utils-merge@^1.0.1: resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== -uuid@^13.0.0: - version "13.0.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.2.tgz#41bc9c07b12f665089c205f6507976adbdf84ff8" - integrity sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw== +uuid@^14.0.0: + version "14.0.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.2.tgz#d5ae03e4db0881c87271f8b0b9bd7312d02c799a" + integrity sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ== uuid@^8.0.0: version "8.3.2"