From 31c57197966f9f28289ea96003c83cc7c2847131 Mon Sep 17 00:00:00 2001 From: Adam Jackson Date: Mon, 22 Jun 2026 15:06:29 -0700 Subject: [PATCH 01/22] Initial OTP conversion --- backend/lib/config.ts | 25 +- backend/lib/db.ts | 5 +- backend/lib/email.ts | 17 +- backend/lib/firebaseAdmin.ts | 3 - backend/lib/otp.ts | 48 ++ backend/lib/rateLimit.ts | 43 + backend/lib/session.ts | 75 ++ backend/lib/utils.ts | 45 +- backend/models/OtpCode.ts | 24 + backend/models/Profile.ts | 4 +- backend/models/RateLimit.ts | 23 + backend/models/Session.ts | 23 + backend/routes/account.ts | 55 +- backend/routes/admin.ts | 28 - backend/routes/auth.ts | 157 ++-- backend/routes/participants.ts | 15 +- backend/routes/profile.ts | 49 +- backend/routes/trips/[tripId]/participants.ts | 11 +- backend/routes/trips/index.ts | 9 +- frontend/components/AccountDropdown.tsx | 4 +- frontend/components/AuthForm.tsx | 146 ++++ frontend/components/EmailChangeForm.tsx | 102 ++- frontend/components/LoginForm.tsx | 93 -- frontend/components/LoginModal.tsx | 31 - frontend/components/Notice.tsx | 8 +- frontend/components/PasswordChangeForm.tsx | 117 --- frontend/components/RequireAuth.tsx | 26 + frontend/components/SignupForm.tsx | 96 --- frontend/hooks/useEmailLogin.ts | 36 - frontend/hooks/useEmailSignup.ts | 38 - frontend/hooks/useFirebaseLogout.ts | 28 - frontend/hooks/useGoogleLogin.ts | 30 - frontend/hooks/useLogout.ts | 23 + frontend/hooks/useRequestCode.ts | 9 + frontend/hooks/useVerifyCode.ts | 20 + frontend/lib/firebase.ts | 33 - frontend/lib/http.ts | 26 +- frontend/lib/logout.ts | 11 + frontend/lib/sessionToken.ts | 26 + frontend/main.tsx | 14 +- frontend/modals/DeleteAccount.tsx | 7 +- frontend/package.json | 1 - frontend/pages/[tripId]/lifelist.tsx | 2 - frontend/pages/[tripId]/participants.tsx | 2 - frontend/pages/[tripId]/settings.tsx | 2 - .../pages/[tripId]/targets/[speciesCode].tsx | 10 +- frontend/pages/accept/[inviteId].tsx | 30 +- frontend/pages/account.tsx | 44 +- frontend/pages/admin.tsx | 39 +- frontend/pages/create.tsx | 2 - frontend/pages/forgot-password.tsx | 67 -- frontend/pages/import-lifelist.tsx | 6 +- frontend/pages/login.tsx | 26 +- frontend/pages/onboarding.tsx | 76 ++ frontend/pages/reset-password.tsx | 102 --- frontend/pages/signup.tsx | 15 +- frontend/pages/support.tsx | 2 +- frontend/pages/trips.tsx | 2 - frontend/providers/profile.tsx | 2 +- frontend/providers/trip.tsx | 7 +- frontend/providers/user.tsx | 34 +- frontend/router.tsx | 42 +- package-lock.json | 799 +----------------- package.json | 1 + scripts/backfill-emails.ts | 131 +++ scripts/package.json | 1 + shared/types.ts | 41 +- 67 files changed, 1226 insertions(+), 1843 deletions(-) create mode 100644 backend/lib/otp.ts create mode 100644 backend/lib/rateLimit.ts create mode 100644 backend/lib/session.ts create mode 100644 backend/models/OtpCode.ts create mode 100644 backend/models/RateLimit.ts create mode 100644 backend/models/Session.ts create mode 100644 frontend/components/AuthForm.tsx delete mode 100644 frontend/components/LoginForm.tsx delete mode 100644 frontend/components/LoginModal.tsx delete mode 100644 frontend/components/PasswordChangeForm.tsx create mode 100644 frontend/components/RequireAuth.tsx delete mode 100644 frontend/components/SignupForm.tsx delete mode 100644 frontend/hooks/useEmailLogin.ts delete mode 100644 frontend/hooks/useEmailSignup.ts delete mode 100644 frontend/hooks/useFirebaseLogout.ts delete mode 100644 frontend/hooks/useGoogleLogin.ts create mode 100644 frontend/hooks/useLogout.ts create mode 100644 frontend/hooks/useRequestCode.ts create mode 100644 frontend/hooks/useVerifyCode.ts delete mode 100644 frontend/lib/firebase.ts create mode 100644 frontend/lib/logout.ts create mode 100644 frontend/lib/sessionToken.ts delete mode 100644 frontend/pages/forgot-password.tsx create mode 100644 frontend/pages/onboarding.tsx delete mode 100644 frontend/pages/reset-password.tsx create mode 100644 scripts/backfill-emails.ts diff --git a/backend/lib/config.ts b/backend/lib/config.ts index 5b25413e..09de69dd 100644 --- a/backend/lib/config.ts +++ b/backend/lib/config.ts @@ -1,3 +1,26 @@ -export const RESET_TOKEN_EXPIRATION = 12; // hours export const OPENBIRDING_API_URL = process.env.OPENBIRDING_API_URL; export const SHARE_CODE_TTL_MINUTES = 10; + +export const IS_DEV = process.env.NODE_ENV !== "production"; + +export const OTP_EXPIRATION_MINUTES = 10; +export const OTP_MAX_ATTEMPTS = 5; + +export const SESSION_INACTIVITY_DAYS = 365; +export const SESSION_REFRESH_THRESHOLD_HOURS = 24; + +type RateRule = { limit: number; windowMs: number }; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; + +export const RATE_LIMITS: Record = { + requestCodeEmail: [ + { limit: 1, windowMs: 30 * SECOND }, + { limit: 5, windowMs: HOUR }, + ], + requestCodeIp: [{ limit: 10, windowMs: HOUR }], + verifyCodeEmail: [{ limit: 10, windowMs: 10 * MINUTE }], + verifyCodeIp: [{ limit: 20, windowMs: 10 * MINUTE }], +}; diff --git a/backend/lib/db.ts b/backend/lib/db.ts index 25957c1e..64b1989e 100644 --- a/backend/lib/db.ts +++ b/backend/lib/db.ts @@ -3,6 +3,9 @@ import Profile from "models/Profile.js"; import Invite from "models/Invite.js"; import Participant from "models/Participant.js"; import TripShareToken from "models/TripShareToken.js"; +import Session from "models/Session.js"; +import OtpCode from "models/OtpCode.js"; +import RateLimit from "models/RateLimit.js"; import mongoose from "mongoose"; let isConnected = false; @@ -44,4 +47,4 @@ export async function connect() { } } -export { Trip, Profile, Invite, Participant, TripShareToken }; +export { Trip, Profile, Invite, Participant, TripShareToken, Session, OtpCode, RateLimit }; diff --git a/backend/lib/email.ts b/backend/lib/email.ts index 536a4899..528314e2 100644 --- a/backend/lib/email.ts +++ b/backend/lib/email.ts @@ -1,5 +1,5 @@ import { Resend } from "resend"; -import { RESET_TOKEN_EXPIRATION } from "lib/config.js"; +import { OTP_EXPIRATION_MINUTES, IS_DEV } from "lib/config.js"; const resend = new Resend(process.env.RESEND_API_KEY); @@ -11,6 +11,11 @@ type Props = { }; export const sendEmail = async ({ to, subject, html, replyTo }: Props) => { + if (IS_DEV) { + console.log(`\n📧 [dev] email not sent\n to: ${to}\n subject: ${subject}\n body: ${html}\n`); + return; + } + await resend.emails.send({ from: "BirdPlan.app ", to, @@ -36,15 +41,15 @@ export const sendInviteEmail = async ({ tripName, fromName, email, url }: invite }); }; -type resetEmailProps = { +type otpEmailProps = { email: string; - url: string; + code: string; }; -export const sendResetEmail = async ({ email, url }: resetEmailProps) => { +export const sendOtpEmail = async ({ email, code }: otpEmailProps) => { await sendEmail({ to: email, - subject: "Reset your BirdPlan.app password", - html: `Hello,

Click the link below to reset your BirdPlan.app password.

Reset Password

This link will expire in ${RESET_TOKEN_EXPIRATION} hours. If you did not request a password reset, please ignore this email.`, + subject: `${code} is your BirdPlan.app sign-in code`, + html: `Hello,

Your BirdPlan.app sign-in code is:

${code}

This code expires in ${OTP_EXPIRATION_MINUTES} minutes. If you didn't request it, you can safely ignore this email.`, }); }; diff --git a/backend/lib/firebaseAdmin.ts b/backend/lib/firebaseAdmin.ts index 8c2d30f3..986756ff 100644 --- a/backend/lib/firebaseAdmin.ts +++ b/backend/lib/firebaseAdmin.ts @@ -15,9 +15,6 @@ if (hasFirebaseConfig && !firebase.apps.length) { }); } -export const admin = firebase; -export const auth = hasFirebaseConfig ? firebase.auth() : null; - export async function uploadMapboxImageToStorage(mapboxImageUrl: string): Promise { if (!hasFirebaseConfig) { console.warn("Firebase not configured, skipping image upload"); diff --git a/backend/lib/otp.ts b/backend/lib/otp.ts new file mode 100644 index 00000000..4c580515 --- /dev/null +++ b/backend/lib/otp.ts @@ -0,0 +1,48 @@ +import crypto from "crypto"; +import dayjs from "dayjs"; +import { HTTPException } from "hono/http-exception"; +import { OtpCode } from "lib/db.js"; +import { sha256, constantTimeEqual } from "lib/session.js"; +import { sendOtpEmail } from "lib/email.js"; +import { OTP_EXPIRATION_MINUTES, OTP_MAX_ATTEMPTS } from "lib/config.js"; + +export const generateCode = () => crypto.randomInt(0, 1_000_000).toString().padStart(6, "0"); + +export async function issueOtp(email: string, ip?: string) { + await OtpCode.updateMany({ email, consumedAt: null }, { $set: { consumedAt: new Date() } }); + + const code = generateCode(); + await OtpCode.create({ + email, + codeHash: sha256(code), + expiresAt: dayjs().add(OTP_EXPIRATION_MINUTES, "minute").toDate(), + ip, + }); + + await sendOtpEmail({ email, code }); +} + +export async function verifyOtp(email: string, code: string) { + const otp = await OtpCode.findOne({ email, consumedAt: null, expiresAt: { $gt: new Date() } }) + .sort({ createdAt: -1 }) + .lean(); + if (!otp) throw new HTTPException(400, { message: "Invalid or expired code" }); + + if (otp.attempts >= OTP_MAX_ATTEMPTS) { + await OtpCode.updateOne({ _id: otp._id }, { $set: { consumedAt: new Date() } }); + throw new HTTPException(400, { message: "Too many attempts. Please request a new code." }); + } + + if (!constantTimeEqual(sha256(code), otp.codeHash)) { + const nextAttempts = otp.attempts + 1; + await OtpCode.updateOne( + { _id: otp._id }, + nextAttempts >= OTP_MAX_ATTEMPTS + ? { $inc: { attempts: 1 }, $set: { consumedAt: new Date() } } + : { $inc: { attempts: 1 } } + ); + throw new HTTPException(400, { message: "Invalid or expired code" }); + } + + await OtpCode.updateOne({ _id: otp._id }, { $set: { consumedAt: new Date() } }); +} diff --git a/backend/lib/rateLimit.ts b/backend/lib/rateLimit.ts new file mode 100644 index 00000000..579f6573 --- /dev/null +++ b/backend/lib/rateLimit.ts @@ -0,0 +1,43 @@ +import type { RateLimit } from "@birdplan/shared"; +import { RateLimit as RateLimitModel } from "lib/db.js"; + +export type RateRule = { limit: number; windowMs: number }; + +type RateScope = { action: string; scopeType: string; scopeValue: string }; + +async function hitRule(scope: RateScope, rule: RateRule): Promise { + const now = Date.now(); + const windowStartThreshold = new Date(now - rule.windowMs); + const match = { ...scope, windowMs: rule.windowMs }; + + const doc = await RateLimitModel.findOneAndUpdate( + { ...match, windowStartAt: { $gt: windowStartThreshold } }, + { $inc: { count: 1 } }, + { new: true } + ).lean(); + + if (doc) { + return doc.count <= rule.limit; + } + + await RateLimitModel.updateOne( + match, + { $set: { count: 1, windowStartAt: new Date(now), expiresAt: new Date(now + rule.windowMs) } }, + { upsert: true } + ); + + return true; +} + +export async function enforceRateLimit( + action: string, + scopeType: string, + scopeValue: string, + rules: RateRule[] +): Promise { + for (const rule of rules) { + const ok = await hitRule({ action, scopeType, scopeValue }, rule); + if (!ok) return false; + } + return true; +} diff --git a/backend/lib/session.ts b/backend/lib/session.ts new file mode 100644 index 00000000..47edc640 --- /dev/null +++ b/backend/lib/session.ts @@ -0,0 +1,75 @@ +import crypto from "crypto"; +import dayjs from "dayjs"; +import type { Session } from "@birdplan/shared"; +import { Session as SessionModel } from "lib/db.js"; +import { SESSION_INACTIVITY_DAYS } from "lib/config.js"; + +const SESSION_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789"; + +function generateSecureRandomString(): string { + const bytes = crypto.randomBytes(24); + let result = ""; + for (let i = 0; i < bytes.length; i++) { + result += SESSION_ALPHABET[bytes[i] >> 3]; + } + return result; +} + +export function sha256(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +export function hashSecret(secret: string): string { + return sha256(secret); +} + +export function constantTimeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return crypto.timingSafeEqual(bufA, bufB); +} + +type SessionMeta = { userAgent?: string; ip?: string }; + +export async function createSession(uid: string, meta: SessionMeta = {}) { + const id = generateSecureRandomString(); + const secret = generateSecureRandomString(); + const now = new Date(); + const expiresAt = dayjs(now).add(SESSION_INACTIVITY_DAYS, "day").toDate(); + + await SessionModel.create({ + _id: id, + secretHash: hashSecret(secret), + uid, + lastActiveAt: now, + expiresAt, + userAgent: meta.userAgent, + ip: meta.ip, + }); + + return { token: `${id}.${secret}`, id }; +} + +export async function validateSessionToken(token: string): Promise { + const parts = token.split("."); + if (parts.length !== 2) return null; + const [id, secret] = parts; + if (!id || !secret) return null; + + const session = await SessionModel.findById(id).lean(); + if (!session) return null; + + if (new Date(session.expiresAt).getTime() <= Date.now()) { + await SessionModel.deleteOne({ _id: id }); + return null; + } + + if (!constantTimeEqual(hashSecret(secret), session.secretHash)) return null; + + return session; +} + +export async function invalidateSession(id: string) { + await SessionModel.deleteOne({ _id: id }); +} diff --git a/backend/lib/utils.ts b/backend/lib/utils.ts index fa3281d0..060b9131 100644 --- a/backend/lib/utils.ts +++ b/backend/lib/utils.ts @@ -1,47 +1,34 @@ import { HTTPException } from "hono/http-exception"; import type { Context } from "hono"; -import { auth } from "lib/firebaseAdmin.js"; import { customAlphabet } from "nanoid"; -import type { Trip, Hotspot } from "@birdplan/shared"; +import type { Trip, Hotspot, Session } from "@birdplan/shared"; +import { validateSessionToken } from "lib/session.js"; export const nanoId = (length: number = 16) => { return customAlphabet("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", length)(); }; -export async function authenticate(c: Context) { - if (!auth) { - throw new HTTPException(503, { message: "Authentication service not available" }); - } - +function getBearerToken(c: Context): string { const authHeader = c.req.header("authorization"); + if (!authHeader?.startsWith("Bearer ")) return ""; + return authHeader.slice("Bearer ".length).trim(); +} - if (!authHeader?.startsWith("Bearer ")) { - throw new HTTPException(401, { message: "Unauthorized" }); - } +export async function authenticate(c: Context): Promise { + const token = getBearerToken(c); + if (!token) throw new HTTPException(401, { message: "Unauthorized" }); - const token = authHeader.split("Bearer ")[1]; + const session = await validateSessionToken(token); + if (!session) throw new HTTPException(401, { message: "Unauthorized" }); - try { - return await auth.verifyIdToken(token); - } catch (error) { - console.error("Firebase auth error:", error); - throw new HTTPException(401, { message: "Unauthorized" }); - } + return session; } -export async function authenticateOptional(c: Context) { - const authHeader = c.req.header("authorization"); - const token = authHeader?.startsWith("Bearer ") ? authHeader.split("Bearer ")[1] : ""; - - if (!auth || !token) { - return null; - } +export async function authenticateOptional(c: Context): Promise { + const token = getBearerToken(c); + if (!token) return null; - try { - return await auth.verifyIdToken(token); - } catch { - return null; - } + return await validateSessionToken(token); } export const getBounds = async (regionString: string) => { diff --git a/backend/models/OtpCode.ts b/backend/models/OtpCode.ts new file mode 100644 index 00000000..a363a312 --- /dev/null +++ b/backend/models/OtpCode.ts @@ -0,0 +1,24 @@ +import type { OtpCode } from "@birdplan/shared"; +import mongoose, { Schema, model, Model } from "mongoose"; +import { nanoId } from "lib/utils.js"; + +const fields: Record, any> = { + _id: { type: String, default: () => nanoId() }, + email: { type: String, required: true }, + codeHash: { type: String, required: true }, + expiresAt: { type: Date, required: true }, + attempts: { type: Number, default: 0 }, + consumedAt: { type: Date, default: null }, + ip: String, +}; + +const OtpCodeSchema = new Schema(fields, { + timestamps: true, +}); + +OtpCodeSchema.index({ email: 1, createdAt: -1 }); +OtpCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +const OtpCodeModel = (mongoose.models.OtpCode as Model) || model("OtpCode", OtpCodeSchema); + +export default OtpCodeModel; diff --git a/backend/models/Profile.ts b/backend/models/Profile.ts index 791811c5..14fa447e 100644 --- a/backend/models/Profile.ts +++ b/backend/models/Profile.ts @@ -6,15 +6,13 @@ const fields: Record, any> = { _id: { type: String, default: () => nanoId() }, uid: { type: String, required: true, unique: true }, name: String, - email: String, + email: { type: String, required: true, lowercase: true }, photoUrl: String, lifelist: { type: [String], default: [] }, lifelistUpdatedAt: { type: Date, default: null }, exceptions: { type: [String], default: [] }, dismissedNoticeId: String, lastActiveAt: { type: Date, default: new Date() }, - resetToken: String, - resetTokenExpires: Date, isAdmin: { type: Boolean, default: false }, }; diff --git a/backend/models/RateLimit.ts b/backend/models/RateLimit.ts new file mode 100644 index 00000000..f48861ec --- /dev/null +++ b/backend/models/RateLimit.ts @@ -0,0 +1,23 @@ +import type { RateLimit } from "@birdplan/shared"; +import mongoose, { Schema, model, Model } from "mongoose"; + +const fields: Record, any> = { + action: { type: String, required: true }, + scopeType: { type: String, required: true }, + scopeValue: { type: String, required: true }, + windowMs: { type: Number, required: true }, + count: { type: Number, default: 0 }, + windowStartAt: { type: Date, required: true }, + expiresAt: { type: Date, required: true }, +}; + +const RateLimitSchema = new Schema(fields, { + timestamps: true, +}); + +RateLimitSchema.index({ action: 1, scopeType: 1, scopeValue: 1, windowMs: 1 }, { unique: true }); +RateLimitSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +const RateLimitModel = (mongoose.models.RateLimit as Model) || model("RateLimit", RateLimitSchema); + +export default RateLimitModel; diff --git a/backend/models/Session.ts b/backend/models/Session.ts new file mode 100644 index 00000000..4f1dba9c --- /dev/null +++ b/backend/models/Session.ts @@ -0,0 +1,23 @@ +import type { Session } from "@birdplan/shared"; +import mongoose, { Schema, model, Model } from "mongoose"; + +const fields: Record, any> = { + _id: { type: String }, + secretHash: { type: String, required: true }, + uid: { type: String, required: true }, + lastActiveAt: { type: Date, required: true }, + expiresAt: { type: Date, required: true }, + userAgent: String, + ip: String, +}; + +const SessionSchema = new Schema(fields, { + timestamps: true, +}); + +SessionSchema.index({ uid: 1 }); +SessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +const SessionModel = (mongoose.models.Session as Model) || model("Session", SessionSchema); + +export default SessionModel; diff --git a/backend/routes/account.ts b/backend/routes/account.ts index e72c2c57..7b269260 100644 --- a/backend/routes/account.ts +++ b/backend/routes/account.ts @@ -1,18 +1,21 @@ import { Hono } from "hono"; import { authenticate } from "lib/utils.js"; -import { connect, Profile, Trip, Participant } from "lib/db.js"; -import { auth as firebaseAuth } from "lib/firebaseAdmin.js"; +import { connect, Profile, Trip, Participant, Session, OtpCode } from "lib/db.js"; +import { issueOtp, verifyOtp } from "lib/otp.js"; import { HTTPException } from "hono/http-exception"; const account = new Hono(); +const normalizeEmail = (email?: string | null) => email?.trim().toLowerCase() || ""; +const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + account.delete("/", async (c) => { const session = await authenticate(c); - const uid = session.uid; await connect(); + const profile = await Profile.findOne({ uid }).select("email").lean(); const tripIds = await Trip.distinct("_id", { ownerId: uid }); await Promise.all([ @@ -20,32 +23,48 @@ account.delete("/", async (c) => { Participant.deleteMany({ uid }), Participant.deleteMany({ tripId: { $in: tripIds } }), Trip.deleteMany({ ownerId: uid }), + Session.deleteMany({ uid }), + profile?.email ? OtpCode.deleteMany({ email: profile.email }) : Promise.resolve(), ]); - await firebaseAuth?.deleteUser(uid); - return c.json({}); }); -account.post("/update-email", async (c) => { +account.post("/request-email-change", async (c) => { const session = await authenticate(c); const { email: rawEmail } = await c.req.json<{ email: string }>(); - const email = rawEmail?.trim().toLowerCase(); - if (!email) throw new HTTPException(400, { message: "Email is required" }); + const email = normalizeEmail(rawEmail); + if (!email || !isValidEmail(email)) throw new HTTPException(400, { message: "A valid email is required" }); - const user = await firebaseAuth?.getUser(session.uid); - if (!user) throw new HTTPException(404, { message: "User not found" }); + await connect(); - if (!user.providerData.some((provider) => provider.providerId === "password")) { - throw new HTTPException(400, { - message: "Cannot update email for accounts using external authentication providers", - }); + const existing = await Profile.findOne({ email }).select("uid").lean(); + if (existing && existing.uid !== session.uid) { + throw new HTTPException(400, { message: "That email is already in use" }); } - await Promise.all([ - firebaseAuth?.updateUser(user.uid, { email }), - Profile.updateOne({ uid: session.uid }, { email }), - ]); + await issueOtp(email); + + return c.json({ ok: true }); +}); + +account.post("/update-email", async (c) => { + const session = await authenticate(c); + const { email: rawEmail, code } = await c.req.json<{ email: string; code: string }>(); + const email = normalizeEmail(rawEmail); + if (!email || !code) throw new HTTPException(400, { message: "Email and code are required" }); + + await connect(); + + const existing = await Profile.findOne({ email }).select("uid").lean(); + if (existing && existing.uid !== session.uid) { + throw new HTTPException(400, { message: "That email is already in use" }); + } + + await verifyOtp(email, code); + + await Profile.updateOne({ uid: session.uid }, { $set: { email } }); + return c.json({ message: "Email updated successfully" }); }); diff --git a/backend/routes/admin.ts b/backend/routes/admin.ts index f05c02e2..bda095a8 100644 --- a/backend/routes/admin.ts +++ b/backend/routes/admin.ts @@ -2,35 +2,10 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { authenticate } from "lib/utils.js"; import { connect, Profile, Trip } from "lib/db.js"; -import { auth as firebaseAuth } from "lib/firebaseAdmin.js"; import type { AdminDashboard, AdminDashboardUser } from "@birdplan/shared"; const admin = new Hono(); -async function getProvidersByUid(uids: string[]) { - const map = new Map(); - if (!firebaseAuth || uids.length === 0) return map; - const auth = firebaseAuth; - - const batches: string[][] = []; - for (let i = 0; i < uids.length; i += 100) { - batches.push(uids.slice(i, i + 100)); - } - - const results = await Promise.all(batches.map((batch) => auth.getUsers(batch.map((uid) => ({ uid }))))); - - for (const result of results) { - for (const user of result.users) { - map.set( - user.uid, - user.providerData.map((provider) => provider.providerId) - ); - } - } - - return map; -} - admin.get("/", async (c) => { const session = await authenticate(c); @@ -54,8 +29,6 @@ admin.get("/", async (c) => { Profile.find({}).select("uid name email photoUrl lastActiveAt createdAt").lean(), ]); - const providersByUid = await getProvidersByUid(profiles.map((profile) => profile.uid)); - const users: AdminDashboardUser[] = profiles.map((profile) => ({ _id: profile._id, uid: profile.uid, @@ -64,7 +37,6 @@ admin.get("/", async (c) => { photoUrl: profile.photoUrl, createdAt: (profile as unknown as { createdAt: Date }).createdAt?.toISOString?.() ?? "", lastActiveAt: profile.lastActiveAt ?? null, - providers: providersByUid.get(profile.uid) ?? [], })); const response: AdminDashboard = { diff --git a/backend/routes/auth.ts b/backend/routes/auth.ts index b5ec713b..693976ed 100644 --- a/backend/routes/auth.ts +++ b/backend/routes/auth.ts @@ -1,83 +1,140 @@ import { Hono } from "hono"; -import { nanoId } from "lib/utils.js"; -import { connect, Profile } from "lib/db.js"; import { HTTPException } from "hono/http-exception"; import dayjs from "dayjs"; -import { RESET_TOKEN_EXPIRATION } from "lib/config.js"; -import { sendResetEmail } from "lib/email.js"; -import { auth as firebaseAuth } from "lib/firebaseAdmin.js"; +import { connect, Profile, Session, Participant } from "lib/db.js"; +import { nanoId, authenticate } from "lib/utils.js"; +import { createSession, invalidateSession } from "lib/session.js"; +import { issueOtp, verifyOtp } from "lib/otp.js"; +import { enforceRateLimit } from "lib/rateLimit.js"; +import { SESSION_INACTIVITY_DAYS, SESSION_REFRESH_THRESHOLD_HOURS, RATE_LIMITS } from "lib/config.js"; const auth = new Hono(); -auth.post("/forgot-password", async (c) => { - const { email } = await c.req.json<{ email: string }>(); - if (!email) throw new HTTPException(400, { message: "Email is required" }); +const normalizeEmail = (email?: string | null) => email?.trim().toLowerCase() || ""; +const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +const getIp = (c: { req: { header: (name: string) => string | undefined } }) => + c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; - await connect(); - const user = await firebaseAuth?.getUserByEmail(email); +async function claimInvite(inviteId: string, email: string, uid: string, name?: string): Promise { + const pending = await Participant.findById(inviteId).lean(); + if (!pending || pending.status !== "pending" || pending.uid) return undefined; + if (normalizeEmail(pending.email) !== email) return undefined; - if (!user || !user.providerData.some((provider) => provider.providerId === "password")) { - console.log("User not found/invalid provider", user?.providerData); - return Response.json({}); + const existing = await Participant.findOne({ tripId: pending.tripId, uid, status: "active" }).lean(); + if (existing) { + await Participant.deleteOne({ _id: pending._id }); + return pending.tripId; } - const resetToken = nanoId(64); - const resetTokenExpires = dayjs().add(RESET_TOKEN_EXPIRATION, "hours").toDate(); - const url = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; + const hasCuratedList = !!pending.lifelist?.length; + try { + const result = await Participant.updateOne( + { _id: inviteId, status: "pending", uid: { $exists: false } }, + { $set: { status: "active", uid, name, ...(hasCuratedList ? {} : { listMode: "world" }) } } + ); + if (result.matchedCount === 0) return undefined; + } catch (err) { + if ((err as { code?: number })?.code === 11000) { + await Participant.deleteOne({ _id: inviteId, status: "pending" }); + return pending.tripId; + } + throw err; + } + return pending.tripId; +} - await Profile.updateOne({ uid: user.uid }, { resetToken, resetTokenExpires }); - await sendResetEmail({ email, url }); - return c.json({}); -}); +auth.post("/request-code", async (c) => { + const { email: rawEmail } = await c.req.json<{ email: string }>(); + const email = normalizeEmail(rawEmail); + const ip = getIp(c); -auth.post("/reset-password", async (c) => { - const { token, password } = await c.req.json<{ token: string; password: string }>(); - if (!token || !password) throw new HTTPException(400, { message: "Missing required fields" }); + if (!email || !isValidEmail(email)) return c.json({ ok: true }); await connect(); - const profile = await Profile.findOne({ resetToken: token }).lean(); - if (!profile || !profile.uid) { - throw new HTTPException(400, { message: "Invalid or expired token" }); - } + const emailOk = await enforceRateLimit("request_code", "email", email, RATE_LIMITS.requestCodeEmail); + const ipOk = await enforceRateLimit("request_code", "ip", ip, RATE_LIMITS.requestCodeIp); + if (!emailOk || !ipOk) throw new HTTPException(429, { message: "Too many requests. Please try again later." }); - const user = await firebaseAuth?.getUser(profile.uid); + await issueOtp(email, ip); - if (!user) { - throw new HTTPException(400, { message: "User not found" }); - } + return c.json({ ok: true }); +}); - if (user.providerData.some((provider) => provider.providerId === "google.com")) { - throw new HTTPException(400, { message: "You must use 'Sign in with Google' to login" }); - } else if (user.providerData.some((provider) => provider.providerId === "apple.com")) { - throw new HTTPException(400, { message: "You must use 'Sign in with Apple' to login" }); - } +auth.post("/verify-code", async (c) => { + const { + email: rawEmail, + code, + inviteId, + } = await c.req.json<{ email: string; code: string; inviteId?: string }>(); + const email = normalizeEmail(rawEmail); + const ip = getIp(c); + + if (!email || !code) throw new HTTPException(400, { message: "Email and code are required" }); - if (!profile.resetTokenExpires || dayjs().isAfter(dayjs(profile.resetTokenExpires))) { - throw new HTTPException(400, { message: "Reset token has expired" }); + await connect(); + + const emailOk = await enforceRateLimit("verify_code", "email", email, RATE_LIMITS.verifyCodeEmail); + const ipOk = await enforceRateLimit("verify_code", "ip", ip, RATE_LIMITS.verifyCodeIp); + if (!emailOk || !ipOk) throw new HTTPException(429, { message: "Too many requests. Please try again later." }); + + await verifyOtp(email, code); + + let profile = await Profile.findOne({ email }).lean(); + let isNewUser = false; + if (!profile) { + try { + profile = (await Profile.create({ uid: nanoId(), email })).toObject(); + isNewUser = true; + } catch (err) { + if ((err as { code?: number })?.code === 11000) { + profile = await Profile.findOne({ email }).lean(); + } else { + throw err; + } + } } + if (!profile) throw new HTTPException(500, { message: "Failed to create account" }); - await firebaseAuth?.updateUser(user.uid, { password }); + const { token } = await createSession(profile.uid, { + userAgent: c.req.header("user-agent"), + ip, + }); - await Profile.updateOne({ uid: user.uid }, { $unset: { resetToken: "", resetTokenExpires: "" } }); + let claimedTripId: string | undefined; + if (inviteId) { + claimedTripId = await claimInvite(inviteId, email, profile.uid, profile.name); + } - return c.json({ message: "Password reset successfully" }); + return c.json({ token, isNewUser, claimedTripId }); }); -auth.get("/verify-reset-token", async (c) => { - const { searchParams } = new URL(c.req.url); - const token = searchParams.get("token"); - - if (!token) throw new HTTPException(400, { message: "Token is required" }); +auth.get("/me", async (c) => { + const session = await authenticate(c); await connect(); - const profile = await Profile.findOne({ resetToken: token }).lean(); + const profile = await Profile.findOne({ uid: session.uid }).lean(); + if (!profile) { + await invalidateSession(session._id); + throw new HTTPException(401, { message: "Unauthorized" }); + } - if (!profile || !profile.resetTokenExpires || dayjs().isAfter(dayjs(profile.resetTokenExpires))) { - throw new HTTPException(400, { message: "Invalid or expired token" }); + const now = Date.now(); + const lastActive = new Date(session.lastActiveAt).getTime(); + if (now - lastActive > SESSION_REFRESH_THRESHOLD_HOURS * 60 * 60 * 1000) { + const nowDate = new Date(); + const expiresAt = dayjs(nowDate).add(SESSION_INACTIVITY_DAYS, "day").toDate(); + await Session.updateOne({ _id: session._id }, { $set: { lastActiveAt: nowDate, expiresAt } }); + await Profile.updateOne({ uid: session.uid }, { $set: { lastActiveAt: nowDate } }); } - return c.json({ isValid: true }); + return c.json(profile); +}); + +auth.post("/logout", async (c) => { + const session = await authenticate(c); + await invalidateSession(session._id); + return c.json({}); }); export default auth; diff --git a/backend/routes/participants.ts b/backend/routes/participants.ts index c798bf46..81b3e27d 100644 --- a/backend/routes/participants.ts +++ b/backend/routes/participants.ts @@ -1,12 +1,13 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { connect, Participant, Profile, Trip } from "lib/db.js"; -import { auth } from "lib/firebaseAdmin.js"; import { authenticate } from "lib/utils.js"; import type { InviteInfo } from "@birdplan/shared"; const participants = new Hono(); +const normalizeEmail = (email?: string | null) => email?.trim().toLowerCase() || ""; + participants.get("/:id/invite", async (c) => { const id: string = c.req.param("id"); @@ -17,14 +18,11 @@ participants.get("/:id/invite", async (c) => { const trip = await Trip.findById(invite.tripId).lean(); if (!trip) throw new HTTPException(404, { message: "This invite no longer exists." }); - const accountExists = invite.email ? await Profile.exists({ email: invite.email }) : false; - const info: InviteInfo = { tripId: invite.tripId, tripName: trip.name, inviterName: trip.ownerName, email: invite.status === "pending" ? invite.email : undefined, - method: accountExists ? "login" : "signup", status: invite.status, }; @@ -52,7 +50,14 @@ participants.patch("/:id/accept", async (c) => { } const profile = await Profile.findOne({ uid: session.uid }).lean(); - const name = profile?.name || session.name || (await auth?.getUser(session.uid))?.displayName || pending.name; + + if (pending.status === "pending" && normalizeEmail(pending.email) !== normalizeEmail(profile?.email)) { + throw new HTTPException(403, { + message: pending.email ? `Sign in as ${pending.email} to accept this invite.` : "This invite cannot be accepted.", + }); + } + + const name = profile?.name || pending.name; const hasCuratedList = !!pending.lifelist?.length; diff --git a/backend/routes/profile.ts b/backend/routes/profile.ts index 9d3adeb0..34f3f0bb 100644 --- a/backend/routes/profile.ts +++ b/backend/routes/profile.ts @@ -1,48 +1,14 @@ import { Hono } from "hono"; import { authenticate } from "lib/utils.js"; -import { connect, Profile } from "lib/db.js"; +import { connect, Profile, Participant } from "lib/db.js"; import { sciNamesToCodes } from "lib/taxonomy.js"; -import { auth } from "lib/firebaseAdmin.js"; import { HTTPException } from "hono/http-exception"; import type { LifelistImportInput, AddToLifelistInput } from "@birdplan/shared"; const profile = new Hono(); -profile.get("/", async (c) => { - const session = await authenticate(c); - - await connect(); - const set: Record = { lastActiveAt: new Date() }; - const tokenName = typeof session.name === "string" && session.name.trim() ? session.name : null; - const tokenEmail = typeof session.email === "string" && session.email.trim() ? session.email.toLowerCase() : null; - const tokenPhotoUrl = typeof session.picture === "string" && session.picture.trim() ? session.picture : null; - - if (tokenName) set.name = tokenName; - if (tokenEmail) set.email = tokenEmail; - if (tokenPhotoUrl) set.photoUrl = tokenPhotoUrl; - - if (!tokenName) { - const existing = await Profile.findOne({ uid: session.uid }).select("name").lean(); - if (!existing?.name) { - const user = await auth?.getUser(session.uid); - if (user?.displayName) set.name = user.displayName; - } - } - - const profile = await Profile.findOneAndUpdate( - { uid: session.uid }, - { - $set: set, - $setOnInsert: { uid: session.uid }, - }, - { upsert: true, new: true } - ).lean(); - if (!profile) throw new HTTPException(500, { message: "Profile not found" }); - - return c.json(profile); -}); - type BodyT = { + name?: string; exceptions?: string[]; dismissedNoticeId?: string; }; @@ -53,15 +19,24 @@ profile.patch("/", async (c) => { await connect(); const data = await c.req.json(); - const allowedFields: string[] = ["exceptions", "dismissedNoticeId"]; + const allowedFields: string[] = ["name", "exceptions", "dismissedNoticeId"]; Object.keys(data).forEach((key) => { if (!allowedFields.includes(key) || !data[key as keyof BodyT]) { delete data[key as keyof BodyT]; } }); + if (typeof data.name === "string") { + data.name = data.name.trim(); + if (!data.name) delete data.name; + } + await Profile.updateOne({ uid: session.uid }, data); + if (data.name) { + await Participant.updateMany({ uid: session.uid }, { $set: { name: data.name } }); + } + return c.json({}); }); diff --git a/backend/routes/trips/[tripId]/participants.ts b/backend/routes/trips/[tripId]/participants.ts index 634a8950..bf0cd964 100644 --- a/backend/routes/trips/[tripId]/participants.ts +++ b/backend/routes/trips/[tripId]/participants.ts @@ -65,6 +65,9 @@ participants.post("/", async (c) => { if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); + const inviter = await Profile.findOne({ uid: session.uid }).select("name").lean(); + const inviterName = inviter?.name || ""; + const body = await c.req.json(); if (body.type === "named") { @@ -102,7 +105,7 @@ participants.post("/", async (c) => { } await sendInviteEmail({ tripName: trip.name, - fromName: session.name || "", + fromName: inviterName, email, url: `${process.env.FRONTEND_URL}/accept/${body.upgradeId}`, }); @@ -128,7 +131,7 @@ participants.post("/", async (c) => { await sendInviteEmail({ tripName: trip.name, - fromName: session.name || "", + fromName: inviterName, email, url: `${process.env.FRONTEND_URL}/accept/${participant._id}`, }); @@ -171,9 +174,11 @@ participants.post("/:id/resend", async (c) => { if (!(await isTripEditor(tripId, session.uid))) throw new HTTPException(403, { message: "Forbidden" }); if (p.status !== "pending" || !p.email) throw new HTTPException(400, { message: "No pending invite to resend" }); + const inviter = await Profile.findOne({ uid: session.uid }).select("name").lean(); + await sendInviteEmail({ tripName: trip.name, - fromName: session.name || "", + fromName: inviter?.name || "", email: p.email, url: `${process.env.FRONTEND_URL}/accept/${p._id}`, }); diff --git a/backend/routes/trips/index.ts b/backend/routes/trips/index.ts index ea9d151c..2eb33154 100644 --- a/backend/routes/trips/index.ts +++ b/backend/routes/trips/index.ts @@ -3,7 +3,7 @@ import { HTTPException } from "hono/http-exception"; import { rateLimiter } from "hono-rate-limiter"; import trip from "./[tripId]/index.js"; import { authenticate, getBounds } from "lib/utils.js"; -import { connect, Trip, Participant, TripShareToken } from "lib/db.js"; +import { connect, Trip, Participant, TripShareToken, Profile } from "lib/db.js"; import { uploadMapboxImageToStorage } from "lib/firebaseAdmin.js"; import { SHARE_CODE_TTL_MINUTES } from "lib/config.js"; import type { TripInput } from "@birdplan/shared"; @@ -118,10 +118,13 @@ trips.post("/", async (c) => { const imgUrl = await uploadMapboxImageToStorage(mapboxImgUrl); await connect(); + const profile = await Profile.findOne({ uid: session.uid }).select("name").lean(); + const ownerName = profile?.name || ""; + const trip = await Trip.create({ ...data, ownerId: session.uid, - ownerName: session.name, + ownerName, bounds, imgUrl, itinerary: [], @@ -132,7 +135,7 @@ trips.post("/", async (c) => { await Participant.create({ tripId: trip._id, uid: session.uid, - name: session.name, + name: ownerName, status: "active", listMode: "world", isOwner: true, diff --git a/frontend/components/AccountDropdown.tsx b/frontend/components/AccountDropdown.tsx index 25017c22..64297cfb 100644 --- a/frontend/components/AccountDropdown.tsx +++ b/frontend/components/AccountDropdown.tsx @@ -10,7 +10,7 @@ import Avatar from "components/Avatar"; import { avatarFromProfile } from "lib/avatar"; import { useUser } from "providers/user"; import { Link, useLocation } from "react-router-dom"; -import useFirebaseLogout from "hooks/useFirebaseLogout"; +import useLogout from "hooks/useLogout"; import { useProfile } from "providers/profile"; import { withReturnTo } from "lib/helpers"; @@ -26,7 +26,7 @@ const AccountDropdown = ({ className, dropUp }: Props) => { const profile = useProfile(); const { lifelist } = profile; const lifelistCount = lifelist?.length || 0; - const { logout } = useFirebaseLogout(); + const { logout } = useLogout(); const location = useLocation(); const asPath = `${location.pathname}${location.search}`; diff --git a/frontend/components/AuthForm.tsx b/frontend/components/AuthForm.tsx new file mode 100644 index 00000000..b8ee47bf --- /dev/null +++ b/frontend/components/AuthForm.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import Input from "components/Input"; +import Field from "components/Field"; +import Button from "components/Button"; +import Alert from "components/Alert"; +import useRequestCode from "hooks/useRequestCode"; +import useVerifyCode from "hooks/useVerifyCode"; +import useNavContext from "hooks/useNavContext"; +import { getPostAuthDest, withReturnTo } from "lib/helpers"; + +type Props = { + heading?: string; + message?: string; + email?: string; + lockEmail?: boolean; + inviteId?: string; +}; + +const RESEND_COOLDOWN = 30; + +export default function AuthForm({ heading, message, email: initialEmail, lockEmail, inviteId }: Props) { + const navigate = useNavigate(); + const navContext = useNavContext(); + const [step, setStep] = React.useState<"email" | "code">("email"); + const [email, setEmail] = React.useState(initialEmail || ""); + const [code, setCode] = React.useState(""); + const [error, setError] = React.useState(null); + const [cooldown, setCooldown] = React.useState(0); + + const requestCode = useRequestCode(); + const verifyCode = useVerifyCode(); + + React.useEffect(() => { + if (cooldown <= 0) return; + const timer = setTimeout(() => setCooldown((value) => value - 1), 1000); + return () => clearTimeout(timer); + }, [cooldown]); + + const sendCode = async (targetEmail: string) => { + setError(null); + try { + await requestCode.mutateAsync({ email: targetEmail }); + setStep("code"); + setCooldown(RESEND_COOLDOWN); + } catch (err: any) { + setError(err.message || "Something went wrong. Please try again."); + } + }; + + const handleEmailChange = (e: React.ChangeEvent) => { + setEmail(e.target.value); + if (step === "code") { + setStep("email"); + setCode(""); + setCooldown(0); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (step === "email") { + if (!email.trim()) return; + sendCode(email.trim().toLowerCase()); + return; + } + setError(null); + try { + const res = await verifyCode.mutateAsync({ email: email.trim().toLowerCase(), code: code.trim(), inviteId }); + const dest = res.claimedTripId ? `/${res.claimedTripId}/lifelist?from=accept` : getPostAuthDest(navContext); + navigate(res.isNewUser ? withReturnTo("/onboarding", dest) : dest); + } catch (err: any) { + setError(err.message || "Invalid or expired code."); + } + }; + + const pending = requestCode.isPending || verifyCode.isPending; + const disabled = step === "email" ? pending || !email.trim() : pending || code.length < 6; + + return ( + <> + {heading &&

{heading}

} + {message &&

{message}

} + + {error && ( + + {error} + + )} + +
+ + + + + {step === "code" && ( + + ) => setCode(e.target.value.replace(/\D/g, ""))} + /> + We sent a code to your inbox + + )} + + + + {step === "code" && ( +
+ {cooldown > 0 ? ( + Resend in {cooldown}s + ) : ( + + )} +
+ )} +
+ + ); +} diff --git a/frontend/components/EmailChangeForm.tsx b/frontend/components/EmailChangeForm.tsx index bdeecaab..9a7a83dc 100644 --- a/frontend/components/EmailChangeForm.tsx +++ b/frontend/components/EmailChangeForm.tsx @@ -1,58 +1,122 @@ import React, { useState } from "react"; import Button from "components/Button"; import Input from "components/Input"; +import Field from "components/Field"; +import Alert from "components/Alert"; import useMutation from "hooks/useMutation"; import toast from "react-hot-toast"; -import Field from "components/Field"; import { useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "react-router-dom"; type Props = { currentEmail: string; }; export default function EmailChangeForm({ currentEmail }: Props) { - const [email, setEmail] = useState(currentEmail); const queryClient = useQueryClient(); - const navigate = useNavigate(); + const [step, setStep] = useState<"email" | "code">("email"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + + const requestMutation = useMutation({ + url: "/account/request-email-change", + method: "POST", + showToastError: false, + onSuccess: () => { + setError(null); + setStep("code"); + }, + onError: (err: any) => setError(err.message || "Something went wrong. Please try again."), + }); - const updateEmailMutation = useMutation({ + const updateMutation = useMutation({ url: "/account/update-email", method: "POST", + showToastError: false, onSuccess: () => { toast.success("Email updated successfully"); - queryClient.invalidateQueries({ queryKey: ["/profile"] }); - navigate("/login?event=emailUpdated"); + queryClient.invalidateQueries({ queryKey: ["/auth/me"] }); + setStep("email"); + setEmail(""); + setCode(""); }, + onError: (err: any) => setError(err.message || "Invalid or expired code."), }); - const handleSubmit = (e: React.FormEvent) => { + const handleRequest = (e: React.FormEvent) => { e.preventDefault(); - if (!email) return; - updateEmailMutation.mutate({ email }); + const normalized = email.trim().toLowerCase(); + if (!normalized || normalized === currentEmail.toLowerCase()) return; + setError(null); + requestMutation.mutate({ email: normalized }); }; - const isDirty = currentEmail !== email; + const handleUpdate = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + updateMutation.mutate({ email: email.trim().toLowerCase(), code: code.trim() }); + }; + + const isDirty = !!email.trim() && email.trim().toLowerCase() !== currentEmail.toLowerCase(); return (
-
-
+ {error && ( + + {error} + + )} + {step === "email" ? ( + ) => setEmail(e.target.value)} required /> -
-

You will need to sign in again after updating your email.

- -
+

We'll send a 6-digit code to your new email to confirm the change.

+ + + ) : ( +
+ + ) => setCode(e.target.value.replace(/\D/g, ""))} + required + autoFocus + /> + +
+ + +
+
+ )}
); } diff --git a/frontend/components/LoginForm.tsx b/frontend/components/LoginForm.tsx deleted file mode 100644 index 7f503529..00000000 --- a/frontend/components/LoginForm.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from "react"; -import { useUser } from "providers/user"; -import useGoogleLogin from "hooks/useGoogleLogin"; -import useEmailLogin from "hooks/useEmailLogin"; -import Input from "components/Input"; -import Button from "components/Button"; -import GoogleIcon from "components/GoogleIcon"; -import { Link } from "react-router-dom"; -import { getForwardReturnTo, withQueryParams, withReturnTo } from "lib/helpers"; -import useNavContext from "hooks/useNavContext"; - -type Props = { - message?: string; - email?: string; -}; - -export default function LoginForm({ message, email }: Props) { - const navContext = useNavContext(); - const [emailLoginLoading, setEmailLoginLoading] = React.useState(false); - const { login: googleLogin, loading: googleLoading } = useGoogleLogin(); - const { login: emailLogin } = useEmailLogin(); - const { loading: userLoading } = useUser(); - - const signupHref = withQueryParams(withReturnTo("/signup", getForwardReturnTo(navContext)), { email }); - - const isLoading = userLoading || googleLoading || emailLoginLoading; - - const handleEmailLogin = async (e: React.FormEvent) => { - e.preventDefault(); - setEmailLoginLoading(true); - - const formData = new FormData(e.currentTarget); - const emailValue = formData.get("email") as string; - const password = formData.get("password") as string; - - await emailLogin(emailValue, password); - setEmailLoginLoading(false); - }; - - return ( - <> -

Welcome back

- {message ? ( -

{message}

- ) : ( -

Sign in to your account to continue

- )} -
-
- -
-
- -
- - Forgot password? - -
-
- -
-
-
-
-
-
- Or -
-
-
- -
-
-

- Don't have an account?{" "} - - Sign up - -

-
- - ); -} diff --git a/frontend/components/LoginModal.tsx b/frontend/components/LoginModal.tsx deleted file mode 100644 index ed675392..00000000 --- a/frontend/components/LoginModal.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from "react"; -import LoginForm from "components/LoginForm"; -import { useUser } from "providers/user"; -import Icon from "components/Icon"; -import ModalWrapper from "components/ModalWrapper"; - -type Props = { - showLoader?: boolean; -}; - -const LoginModal = ({ showLoader = true }: Props) => { - const { loading, user } = useUser(); - if (user?.uid && !loading) return null; - if (!showLoader && loading) return null; - - return ( - {}}> -
- {loading ? ( -
- -
- ) : ( - - )} -
-
- ); -}; - -export default LoginModal; diff --git a/frontend/components/Notice.tsx b/frontend/components/Notice.tsx index 407028bb..a9275e44 100644 --- a/frontend/components/Notice.tsx +++ b/frontend/components/Notice.tsx @@ -18,10 +18,10 @@ export default function Notice() { url: "/profile", method: "PATCH", onMutate: async (data: any) => { - await queryClient.cancelQueries({ queryKey: [`/profile`] }); - const prevData = queryClient.getQueryData([`/profile`]); + await queryClient.cancelQueries({ queryKey: ["/auth/me"] }); + const prevData = queryClient.getQueryData(["/auth/me"]); - queryClient.setQueryData([`/profile`], (old) => { + queryClient.setQueryData(["/auth/me"], (old) => { if (!old) return old; return { ...old, dismissedNoticeId: data.dismissedNoticeId }; }); @@ -29,7 +29,7 @@ export default function Notice() { return { prevData }; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [`/profile`] }); + queryClient.invalidateQueries({ queryKey: ["/auth/me"] }); }, }); diff --git a/frontend/components/PasswordChangeForm.tsx b/frontend/components/PasswordChangeForm.tsx deleted file mode 100644 index d0e5a8c2..00000000 --- a/frontend/components/PasswordChangeForm.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { useState } from "react"; -import Button from "components/Button"; -import Input from "components/Input"; -import toast from "react-hot-toast"; -import Field from "components/Field"; -import { useNavigate } from "react-router-dom"; -import { auth } from "lib/firebase"; -import { EmailAuthProvider, reauthenticateWithCredential, updatePassword } from "firebase/auth"; - -export default function PasswordChangeForm() { - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const navigate = useNavigate(); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!currentPassword) { - toast.error("Current password is required"); - return; - } - - if (newPassword.length < 6) { - toast.error("Password must be at least 6 characters"); - return; - } - - if (newPassword !== confirmPassword) { - toast.error("Passwords do not match"); - return; - } - - setIsLoading(true); - - try { - const user = auth?.currentUser; - if (!user || !user.email) { - throw new Error("User not found"); - } - - const credential = EmailAuthProvider.credential(user.email, currentPassword); - await reauthenticateWithCredential(user, credential); - - await updatePassword(user, newPassword); - - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - toast.success("Password updated successfully"); - navigate("/login?event=passwordUpdated"); - } catch (error: any) { - if (error.code === "auth/wrong-password") { - toast.error("Current password is incorrect"); - } else if (error.code === "auth/too-many-requests") { - toast.error("Too many attempts. Please try again later."); - } else if (error.code === "auth/requires-recent-login") { - toast.error("Please sign in again before changing your password"); - navigate("/login"); - } else { - toast.error("Error updating password. Please try again."); - } - } finally { - setIsLoading(false); - } - }; - - return ( -
-
- - ) => setCurrentPassword(e.target.value)} - required - /> - -
-
- - ) => setNewPassword(e.target.value)} - required - /> - -
-
- - ) => setConfirmPassword(e.target.value)} - required - /> - -
- -

You will need to sign in again after updating your password.

- - -
- ); -} diff --git a/frontend/components/RequireAuth.tsx b/frontend/components/RequireAuth.tsx new file mode 100644 index 00000000..857fb0ff --- /dev/null +++ b/frontend/components/RequireAuth.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useUser } from "providers/user"; +import Icon from "components/Icon"; +import { withReturnTo } from "lib/helpers"; + +const RequireAuth = () => { + const { user, loading } = useUser(); + const location = useLocation(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + return ; +}; + +export default RequireAuth; diff --git a/frontend/components/SignupForm.tsx b/frontend/components/SignupForm.tsx deleted file mode 100644 index c2e4e9ff..00000000 --- a/frontend/components/SignupForm.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from "react"; -import { useUser } from "providers/user"; -import useGoogleLogin from "hooks/useGoogleLogin"; -import useEmailSignup from "hooks/useEmailSignup"; -import Input from "components/Input"; -import Button from "components/Button"; -import GoogleIcon from "components/GoogleIcon"; -import { Link } from "react-router-dom"; -import toast from "react-hot-toast"; -import { getForwardReturnTo, withQueryParams, withReturnTo } from "lib/helpers"; -import useNavContext from "hooks/useNavContext"; - -type Props = { - message?: string; - email?: string; -}; - -export default function SignupForm({ message, email }: Props) { - const navContext = useNavContext(); - const { signup: emailSignup, loading: emailSignupLoading } = useEmailSignup(); - const { login: googleLogin, loading: googleLoading } = useGoogleLogin(); - const { loading: userLoading } = useUser(); - - const loginHref = withQueryParams(withReturnTo("/login", getForwardReturnTo(navContext)), { email }); - - const isLoading = userLoading || emailSignupLoading || googleLoading; - - const handleFormSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - const formData = new FormData(e.currentTarget); - const name = formData.get("name") as string; - const emailValue = formData.get("email") as string; - const password = formData.get("password") as string; - const confirmPassword = formData.get("confirmPassword") as string; - - if (password !== confirmPassword) { - toast.error("Passwords do not match."); - return; - } - await emailSignup(name, emailValue, password); - }; - - return ( - <> -

Let's get started

- {message ? ( -

{message}

- ) : ( -

Create an account to start planning

- )} -
-
- -
-
- -
-
- - -
- -
-
-
-
-
-
- Or -
-
-
- -
-
-

- Already have an account?{" "} - - Sign in - -

-
- - ); -} diff --git a/frontend/hooks/useEmailLogin.ts b/frontend/hooks/useEmailLogin.ts deleted file mode 100644 index fb754fd2..00000000 --- a/frontend/hooks/useEmailLogin.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { auth } from "lib/firebase"; -import { signInWithEmailAndPassword } from "firebase/auth"; -import toast from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; -import { useState } from "react"; -import { getPostAuthDest } from "lib/helpers"; -import useNavContext from "hooks/useNavContext"; - -export default function useEmailLogin() { - const navigate = useNavigate(); - const navContext = useNavContext(); - const [loading, setLoading] = useState(false); - - const login = async (email: string, password: string, disableLoader = false) => { - setLoading(true); - const toastId = disableLoader ? undefined : toast.loading("Signing in..."); - try { - if (!auth) throw new Error("Firebase auth not initialized"); - await signInWithEmailAndPassword(auth, email, password); - navigate(getPostAuthDest(navContext)); - toast.dismiss(toastId); - } catch (error: any) { - if (error.code === "auth/wrong-password") { - toast.error("Invalid password", { id: toastId }); - } else if (error.code === "auth/too-many-requests") { - toast.error("Too many attempts. Please try again later.", { id: toastId }); - } else { - toast.error("Error signing in", { id: toastId }); - } - } finally { - setLoading(false); - } - }; - - return { login, loading }; -} diff --git a/frontend/hooks/useEmailSignup.ts b/frontend/hooks/useEmailSignup.ts deleted file mode 100644 index 6e80ef08..00000000 --- a/frontend/hooks/useEmailSignup.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { auth } from "lib/firebase"; -import { createUserWithEmailAndPassword, updateProfile } from "firebase/auth"; -import toast from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; -import { useState } from "react"; -import { getPostAuthDest } from "lib/helpers"; -import useNavContext from "hooks/useNavContext"; - -export default function useEmailSignup() { - const navigate = useNavigate(); - const navContext = useNavContext(); - const [loading, setLoading] = useState(false); - - const signup = async (name: string, email: string, password: string) => { - setLoading(true); - const toastId = toast.loading("Creating account..."); - try { - if (!auth) throw new Error("Firebase auth not initialized"); - const userCredential = await createUserWithEmailAndPassword(auth, email, password); - await updateProfile(userCredential.user, { displayName: name }); - toast.success("Account created successfully!", { id: toastId }); - navigate(getPostAuthDest(navContext)); - } catch (error: any) { - console.error("Signup error:", error); - if (error.code === "auth/email-already-in-use") { - toast.error("Email address is already in use.", { id: toastId }); - } else if (error.code === "auth/weak-password") { - toast.error("Password is too weak. Please choose a stronger password.", { id: toastId }); - } else { - toast.error("Error creating account. Please try again.", { id: toastId }); - } - } finally { - setLoading(false); - } - }; - - return { signup, loading }; -} diff --git a/frontend/hooks/useFirebaseLogout.ts b/frontend/hooks/useFirebaseLogout.ts deleted file mode 100644 index 1b7fc90a..00000000 --- a/frontend/hooks/useFirebaseLogout.ts +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react"; -import { auth } from "lib/firebase"; -import { signOut } from "firebase/auth"; -import { useNavigate } from "react-router-dom"; -import { useQueryClient } from "@tanstack/react-query"; - -export default function useFirebaseLogout() { - const [loading, setLoading] = React.useState(false); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - - const logout = async () => { - setLoading(true); - try { - if (!auth) throw new Error("Firebase auth not initialized"); - await signOut(auth); - queryClient.clear(); - navigate("/"); - } catch (error) { - alert("Error logging out"); - console.error(error); - } finally { - setLoading(false); - } - }; - - return { logout, loading }; -} diff --git a/frontend/hooks/useGoogleLogin.ts b/frontend/hooks/useGoogleLogin.ts deleted file mode 100644 index 6903020a..00000000 --- a/frontend/hooks/useGoogleLogin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import { auth } from "lib/firebase"; -import { signInWithPopup, GoogleAuthProvider } from "firebase/auth"; -import toast from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; -import { getPostAuthDest } from "lib/helpers"; -import useNavContext from "hooks/useNavContext"; - -export default function useGoogleLogin() { - const [loading, setLoading] = React.useState(false); - const navigate = useNavigate(); - const navContext = useNavContext(); - - const login = async () => { - setLoading(true); - try { - if (!auth) throw new Error("Firebase auth not initialized"); - const provider = new GoogleAuthProvider(); - await signInWithPopup(auth, provider); - navigate(getPostAuthDest(navContext)); - } catch (error) { - toast.error("Login failed"); - console.error(error); - } finally { - setLoading(false); - } - }; - - return { login, loading }; -} diff --git a/frontend/hooks/useLogout.ts b/frontend/hooks/useLogout.ts new file mode 100644 index 00000000..dae0d893 --- /dev/null +++ b/frontend/hooks/useLogout.ts @@ -0,0 +1,23 @@ +import React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { mutate } from "lib/http"; +import { teardownSession } from "lib/logout"; + +export default function useLogout() { + const [loading, setLoading] = React.useState(false); + const queryClient = useQueryClient(); + + const logout = async () => { + setLoading(true); + try { + await mutate("POST", "/auth/logout"); + } catch (error) { + console.error(error); + } finally { + await teardownSession(queryClient); + window.location.href = "/"; + } + }; + + return { logout, loading }; +} diff --git a/frontend/hooks/useRequestCode.ts b/frontend/hooks/useRequestCode.ts new file mode 100644 index 00000000..7fd50c05 --- /dev/null +++ b/frontend/hooks/useRequestCode.ts @@ -0,0 +1,9 @@ +import useMutation from "hooks/useMutation"; + +export default function useRequestCode() { + return useMutation<{ ok: boolean }, { email: string }>({ + url: "/auth/request-code", + method: "POST", + showToastError: false, + }); +} diff --git a/frontend/hooks/useVerifyCode.ts b/frontend/hooks/useVerifyCode.ts new file mode 100644 index 00000000..7290c51b --- /dev/null +++ b/frontend/hooks/useVerifyCode.ts @@ -0,0 +1,20 @@ +import { useQueryClient } from "@tanstack/react-query"; +import useMutation from "hooks/useMutation"; +import { setSessionToken } from "lib/sessionToken"; + +export type VerifyCodeResponse = { token: string; isNewUser: boolean; claimedTripId?: string }; +export type VerifyCodeInput = { email: string; code: string; inviteId?: string }; + +export default function useVerifyCode() { + const queryClient = useQueryClient(); + + return useMutation({ + url: "/auth/verify-code", + method: "POST", + showToastError: false, + onSuccess: async (data) => { + setSessionToken(data.token); + await queryClient.invalidateQueries({ queryKey: ["/auth/me"] }); + }, + }); +} diff --git a/frontend/lib/firebase.ts b/frontend/lib/firebase.ts deleted file mode 100644 index 5f892582..00000000 --- a/frontend/lib/firebase.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { initializeApp } from "firebase/app"; -import { getAuth, onAuthStateChanged, Auth } from "firebase/auth"; - -const hasFirebaseConfig = !!( - import.meta.env.VITE_FIREBASE_KEY && - import.meta.env.VITE_FIREBASE_SENDER_ID && - import.meta.env.VITE_FIREBASE_APP_ID -); - -let auth: Auth | null = null; -let authReady: Promise = Promise.resolve(); - -if (hasFirebaseConfig) { - const firebaseConfig = { - apiKey: import.meta.env.VITE_FIREBASE_KEY, - authDomain: "bird-planner.firebaseapp.com", - projectId: "bird-planner", - messagingSenderId: import.meta.env.VITE_FIREBASE_SENDER_ID, - appId: import.meta.env.VITE_FIREBASE_APP_ID, - storageBucket: "bird-planner.appspot.com", - }; - - const app = initializeApp(firebaseConfig); - auth = getAuth(app); - authReady = new Promise((resolve) => { - const unsubscribe = onAuthStateChanged(auth!, () => { - unsubscribe(); - resolve(); - }); - }); -} - -export { auth, authReady }; diff --git a/frontend/lib/http.ts b/frontend/lib/http.ts index 1b519a1d..1d5f0015 100644 --- a/frontend/lib/http.ts +++ b/frontend/lib/http.ts @@ -1,10 +1,15 @@ import { toast } from "react-hot-toast"; -import { auth, authReady } from "lib/firebase"; +import { getSessionToken } from "lib/sessionToken"; type Params = { [key: string]: string | number | boolean; }; +let onUnauthorized: (() => void) | null = null; +export const setUnauthorizedHandler = (fn: () => void) => { + onUnauthorized = fn; +}; + export const get = async (url: string, params: Params, showLoading?: boolean) => { const cleanParams = Object.keys(params).reduce((accumulator: any, key) => { if (params[key]) accumulator[key] = params[key]; @@ -20,11 +25,7 @@ export const get = async (url: string, params: Params, showLoading?: boolean) => if (showLoading) toast.loading("Loading...", { id: url }); const isBackend = urlWithParams.startsWith(import.meta.env.VITE_API_URL); - let token: string | undefined; - if (isBackend) { - await authReady; - token = await auth?.currentUser?.getIdToken(); - } + const token = isBackend ? getSessionToken() : undefined; const res = await fetch(urlWithParams, { method: "GET", headers: isBackend ? { Authorization: `Bearer ${token || ""}` } : undefined, @@ -37,7 +38,10 @@ export const get = async (url: string, params: Params, showLoading?: boolean) => json = await res.json(); } catch (error) {} if (!res.ok) { - if (res.status === 401) throw new Error("Unauthorized"); + if (res.status === 401) { + if (isBackend) onUnauthorized?.(); + throw new Error("Unauthorized"); + } if (res.status === 403) throw new Error("Forbidden"); if (res.status === 404) throw new Error(json.message && json.message !== "Not Found" ? json.message : "Route not found"); if (res.status === 405) throw new Error("Method not allowed"); @@ -48,8 +52,7 @@ export const get = async (url: string, params: Params, showLoading?: boolean) => }; export const mutate = async (method: "POST" | "PUT" | "DELETE" | "PATCH", url: string, data?: any) => { - await authReady; - const token = await auth?.currentUser?.getIdToken(); + const token = getSessionToken(); const fullUrl = `${import.meta.env.VITE_API_URL}${url}`; const res = await fetch(fullUrl, { method, @@ -66,7 +69,10 @@ export const mutate = async (method: "POST" | "PUT" | "DELETE" | "PATCH", url: s } catch (error) {} if (!res.ok) { - if (res.status === 401) throw new Error("Unauthorized"); + if (res.status === 401) { + onUnauthorized?.(); + throw new Error("Unauthorized"); + } if (res.status === 403) throw new Error("Forbidden"); if (res.status === 404) throw new Error(json?.message && json.message !== "Not Found" ? json.message : "Route not found"); diff --git a/frontend/lib/logout.ts b/frontend/lib/logout.ts new file mode 100644 index 00000000..bc3adda1 --- /dev/null +++ b/frontend/lib/logout.ts @@ -0,0 +1,11 @@ +import * as idbKeyval from "idb-keyval"; +import type { QueryClient } from "@tanstack/react-query"; +import { clearSessionToken } from "lib/sessionToken"; + +export const IDB_CACHE_KEY = "BIRDPLAN_QUERY_CACHE"; + +export async function teardownSession(queryClient: QueryClient) { + clearSessionToken(); + queryClient.clear(); + await idbKeyval.del(IDB_CACHE_KEY); +} diff --git a/frontend/lib/sessionToken.ts b/frontend/lib/sessionToken.ts new file mode 100644 index 00000000..755673cc --- /dev/null +++ b/frontend/lib/sessionToken.ts @@ -0,0 +1,26 @@ +import React from "react"; + +const TOKEN_KEY = "bp_session"; + +const listeners = new Set<() => void>(); +let current: string | null = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) : null; + +export const getSessionToken = () => current; + +export const setSessionToken = (token: string | null) => { + current = token; + if (typeof localStorage !== "undefined") { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); + } + listeners.forEach((listener) => listener()); +}; + +export const clearSessionToken = () => setSessionToken(null); + +const subscribe = (callback: () => void) => { + listeners.add(callback); + return () => listeners.delete(callback); +}; + +export const useSessionToken = () => React.useSyncExternalStore(subscribe, getSessionToken, () => null); diff --git a/frontend/main.tsx b/frontend/main.tsx index 71e00553..3bb32952 100644 --- a/frontend/main.tsx +++ b/frontend/main.tsx @@ -6,11 +6,12 @@ import { persistQueryClient } from "@tanstack/react-query-persist-client"; import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister"; import { toast } from "react-hot-toast"; import * as idbKeyval from "idb-keyval"; -import { get } from "lib/http"; +import { get, setUnauthorizedHandler } from "lib/http"; +import { teardownSession, IDB_CACHE_KEY } from "lib/logout"; import ErrorBoundary from "components/ErrorBoundary"; import { router } from "router"; -const QUERY_CACHE_BUSTER = "birdplan-cache-v1"; +const QUERY_CACHE_BUSTER = "birdplan-cache-v2"; const queryClient = new QueryClient({ defaultOptions: { @@ -62,11 +63,18 @@ const idbStorage = { persistQueryClient({ queryClient, - persister: createAsyncStoragePersister({ storage: idbStorage, key: "BIRDPLAN_QUERY_CACHE" }), + persister: createAsyncStoragePersister({ storage: idbStorage, key: IDB_CACHE_KEY }), maxAge: 30 * 24 * 60 * 60 * 1000, buster: QUERY_CACHE_BUSTER, }); +setUnauthorizedHandler(async () => { + await teardownSession(queryClient); + if (!window.location.pathname.startsWith("/login")) { + window.location.href = "/login"; + } +}); + createRoot(document.getElementById("root")!).render( diff --git a/frontend/modals/DeleteAccount.tsx b/frontend/modals/DeleteAccount.tsx index 073d7052..7d4ff572 100644 --- a/frontend/modals/DeleteAccount.tsx +++ b/frontend/modals/DeleteAccount.tsx @@ -1,16 +1,17 @@ import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import { Header, Body, Footer, useModal } from "providers/modals"; import useMutation from "hooks/useMutation"; import toast from "react-hot-toast"; import Button from "components/Button"; -import useFirebaseLogout from "hooks/useFirebaseLogout"; +import { teardownSession } from "lib/logout"; export default function DeleteAccount() { const [confirmInput, setConfirmInput] = useState(""); const navigate = useNavigate(); const { close } = useModal(); - const { logout } = useFirebaseLogout(); + const queryClient = useQueryClient(); const CONFIRM_TEXT = "DELETE"; const isConfirmed = confirmInput === CONFIRM_TEXT; @@ -20,7 +21,7 @@ export default function DeleteAccount() { method: "DELETE", onSuccess: async () => { close(); - await logout(); + await teardownSession(queryClient); toast.success("Your account has been deleted"); navigate("/"); }, diff --git a/frontend/package.json b/frontend/package.json index 0e4e3495..c2a5f480 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.0", - "firebase": "^9.6.8", "idb-keyval": "^6.2.1", "lucide-react": "^1.21.0", "mapbox-gl": "^2.13.0", diff --git a/frontend/pages/[tripId]/lifelist.tsx b/frontend/pages/[tripId]/lifelist.tsx index aeb8cf6c..b70dd8b1 100644 --- a/frontend/pages/[tripId]/lifelist.tsx +++ b/frontend/pages/[tripId]/lifelist.tsx @@ -6,7 +6,6 @@ import Icon from "components/Icon"; import Button from "components/Button"; import Card from "components/Card"; import NotFound from "components/NotFound"; -import LoginModal from "components/LoginModal"; import LifelistEditor from "components/LifelistEditor"; import { useTrip } from "providers/trip"; import useLifelistMode from "hooks/useLifelistMode"; @@ -58,7 +57,6 @@ export default function TripLifelist() {
-
); } diff --git a/frontend/pages/[tripId]/participants.tsx b/frontend/pages/[tripId]/participants.tsx index 02defbc4..5131b606 100644 --- a/frontend/pages/[tripId]/participants.tsx +++ b/frontend/pages/[tripId]/participants.tsx @@ -7,7 +7,6 @@ import Button from "components/Button"; import Card from "components/Card"; import Input from "components/Input"; import NotFound from "components/NotFound"; -import LoginModal from "components/LoginModal"; import ParticipantRow from "components/ParticipantRow"; import { useTrip } from "providers/trip"; import { useModal } from "providers/modals"; @@ -104,7 +103,6 @@ export default function TripParticipants() {