diff --git a/backend/index.ts b/backend/index.ts index 4725ea5a..011b4d77 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -4,7 +4,7 @@ import trips from "routes/trips/index.js"; import account from "routes/account.js"; import profile from "routes/profile.js"; import auth from "routes/auth.js"; -import support from "routes/support.js"; +import contact from "routes/contact.js"; import taxonomy from "routes/taxonomy.js"; import region from "routes/region.js"; import ebirdProxy from "routes/ebird-proxy.js"; @@ -25,7 +25,7 @@ app.route("/v1/profile", profile); app.route("/v1/account", account); app.route("/v1/trips", trips); app.route("/v1/auth", auth); -app.route("/v1/support", support); +app.route("/v1/contact", contact); app.route("/v1/taxonomy", taxonomy); app.route("/v1/region", region); app.route("/v1/ebird-proxy", ebirdProxy); @@ -37,9 +37,9 @@ app.notFound((c) => { }); app.onError((err, c) => { - const message = err instanceof Error ? err.message : "Internal Server Error"; - const status = err instanceof HTTPException ? err.status : 500; - return c.json({ message }, status); + if (err instanceof HTTPException) return c.json({ message: err.message }, err.status); + console.error(err); + return c.json({ message: "Something went wrong. Please try again." }, 500); }); serve( diff --git a/backend/lib/config.ts b/backend/lib/config.ts index 5b25413e..8b840402 100644 --- a/backend/lib/config.ts +++ b/backend/lib/config.ts @@ -1,3 +1,33 @@ -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 INVITE_EXPIRATION_DAYS = 7; + +export const MAGIC_LINK_EXPIRATION_DAYS = 7; + +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: 2, windowMs: 30 * SECOND }, + { limit: 5, windowMs: HOUR }, + ], + requestCodeIp: [{ limit: 10, windowMs: HOUR }], + verifyCodeEmail: [{ limit: 10, windowMs: 10 * MINUTE }], + verifyCodeIp: [{ limit: 20, windowMs: 10 * MINUTE }], + otpNotReceivedEmail: [{ limit: 1, windowMs: 10 * MINUTE }], + otpNotReceivedIp: [{ limit: 10, windowMs: HOUR }], + redeemMagicLinkIp: [{ limit: 20, windowMs: 10 * MINUTE }], +}; diff --git a/backend/lib/db.ts b/backend/lib/db.ts index 25957c1e..2fb85b1f 100644 --- a/backend/lib/db.ts +++ b/backend/lib/db.ts @@ -1,8 +1,13 @@ import Trip from "models/Trip.js"; -import Profile from "models/Profile.js"; +import User from "models/User.js"; import Invite from "models/Invite.js"; import Participant from "models/Participant.js"; -import TripShareToken from "models/TripShareToken.js"; +import IntegrationToken from "models/IntegrationToken.js"; +import Session from "models/Session.js"; +import OtpCode from "models/OtpCode.js"; +import MagicLink from "models/MagicLink.js"; +import RateLimit from "models/RateLimit.js"; +import Log from "models/Log.js"; import mongoose from "mongoose"; let isConnected = false; @@ -44,4 +49,4 @@ export async function connect() { } } -export { Trip, Profile, Invite, Participant, TripShareToken }; +export { Trip, User, Invite, Participant, IntegrationToken, Session, OtpCode, MagicLink, RateLimit, Log }; diff --git a/backend/lib/email.ts b/backend/lib/email.ts index 536a4899..eee61467 100644 --- a/backend/lib/email.ts +++ b/backend/lib/email.ts @@ -1,5 +1,11 @@ import { Resend } from "resend"; -import { RESET_TOKEN_EXPIRATION } from "lib/config.js"; +import { HTTPException } from "hono/http-exception"; +import { OTP_EXPIRATION_MINUTES, INVITE_EXPIRATION_DAYS, IS_DEV } from "lib/config.js"; +import { sendNtfyNotification } from "lib/notify.js"; + +const QUOTA_NOTIFY_BUCKETS = [0.5, 0.7, 0.8, 0.9, 0.95, 1.0]; +const RESEND_DAILY_LIMIT = 100; +const RESEND_MONTHLY_LIMIT = 3000; const resend = new Resend(process.env.RESEND_API_KEY); @@ -10,14 +16,49 @@ type Props = { replyTo?: string; }; +const notifyQuotaUsage = async (label: string, used: number, limit: number) => { + if (!Number.isFinite(used)) return; + + const usedBefore = used - 1; + const crossed = QUOTA_NOTIFY_BUCKETS.find((bucket) => { + const threshold = Math.ceil(limit * bucket); + return usedBefore < threshold && used >= threshold; + }); + if (crossed === undefined) return; + + await sendNtfyNotification( + "āš ļø BirdPlan email quota", + `${label} email quota crossed ${Math.round(crossed * 100)}% — ${used}/${limit} sent.`, + ); +}; + +const notifyQuotas = async (headers: Record | null) => { + await notifyQuotaUsage("Daily", Number(headers?.["x-resend-daily-quota"]), RESEND_DAILY_LIMIT); + await notifyQuotaUsage("Monthly", Number(headers?.["x-resend-monthly-quota"]), RESEND_MONTHLY_LIMIT); +}; + export const sendEmail = async ({ to, subject, html, replyTo }: Props) => { - await resend.emails.send({ + if (IS_DEV) { + console.log(`\nšŸ“§ [dev] email not sent\n to: ${to}\n subject: ${subject}\n body: ${html}\n`); + return; + } + + const { error, headers } = await resend.emails.send({ from: "BirdPlan.app ", to, subject, html, replyTo, }); + + await notifyQuotas(headers); + + if (error) { + console.error(`[resend] failed to send email to ${to}: ${error.name} — ${error.message}`); + throw new HTTPException(503, { + message: "We're unable to send emails right now. Please try again in a few minutes.", + }); + } }; type inviteEmailProps = { @@ -31,20 +72,20 @@ export const sendInviteEmail = async ({ tripName, fromName, email, url }: invite await sendEmail({ to: email, subject: `${fromName} has invited you to join ${tripName}`, - html: `Hello,

${fromName} invited to join their trip called '${tripName}'.

Accept Invite`, + html: `Hello,

${fromName} invited you to join their trip called '${tripName}'.

Accept Invite

This invite expires in ${INVITE_EXPIRATION_DAYS} days.`, replyTo: email, }); }; -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/log.ts b/backend/lib/log.ts new file mode 100644 index 00000000..35e465e6 --- /dev/null +++ b/backend/lib/log.ts @@ -0,0 +1,18 @@ +import { connect, Log } from "lib/db.js"; + +type LogInput = { + type: string; + email?: string; + userId?: string; + ip?: string; + data?: Record; +}; + +export const logEvent = async (input: LogInput) => { + try { + await connect(); + await Log.create(input); + } catch (err) { + console.error(`[log] failed to write ${input.type}: ${err instanceof Error ? err.message : err}`); + } +}; diff --git a/backend/lib/magicLink.ts b/backend/lib/magicLink.ts new file mode 100644 index 00000000..315e00c1 --- /dev/null +++ b/backend/lib/magicLink.ts @@ -0,0 +1,45 @@ +import dayjs from "dayjs"; +import { HTTPException } from "hono/http-exception"; +import { connect, MagicLink } from "lib/db.js"; +import { nanoId } from "lib/utils.js"; +import { sha256, createSession } from "lib/session.js"; +import { MAGIC_LINK_EXPIRATION_DAYS } from "lib/config.js"; + +export async function issueMagicLink(userId: string, createdByUserId?: string) { + await connect(); + + const token = nanoId(40); + const expiresAt = dayjs().add(MAGIC_LINK_EXPIRATION_DAYS, "day").toDate(); + + await MagicLink.create({ + tokenHash: sha256(token), + userId, + expiresAt, + createdByUserId, + }); + + return { token, expiresAt }; +} + +type RedeemMeta = { userAgent?: string; ip?: string }; + +export async function redeemMagicLink(token: string, meta: RedeemMeta = {}) { + await connect(); + + const now = new Date(); + const link = await MagicLink.findOneAndUpdate( + { tokenHash: sha256(token), consumedAt: null, expiresAt: { $gt: now } }, + { $set: { consumedAt: now } }, + { new: true } + ).lean(); + + if (!link) throw new HTTPException(400, { message: "This link is invalid or has expired." }); + + try { + const { token: sessionToken } = await createSession(link.userId, meta); + return { sessionToken, userId: link.userId }; + } catch (err) { + await MagicLink.updateOne({ _id: link._id }, { $set: { consumedAt: null } }); + throw err; + } +} diff --git a/backend/lib/notify.ts b/backend/lib/notify.ts new file mode 100644 index 00000000..df5c57f4 --- /dev/null +++ b/backend/lib/notify.ts @@ -0,0 +1,13 @@ +export const sendNtfyNotification = async (title: string, message: string) => { + const topic = process.env.NTFY_TOPIC; + if (!topic) return; + try { + await fetch(`https://ntfy.sh/${topic}`, { + method: "POST", + headers: { Title: title }, + body: message, + }); + } catch (err) { + console.error(`[ntfy] failed to send notification: ${err instanceof Error ? err.message : err}`); + } +}; diff --git a/backend/lib/otp.ts b/backend/lib/otp.ts new file mode 100644 index 00000000..541eb3f2 --- /dev/null +++ b/backend/lib/otp.ts @@ -0,0 +1,51 @@ +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 now = new Date(); + + const otp = await OtpCode.findOneAndUpdate( + { email, consumedAt: null, expiresAt: { $gt: now }, attempts: { $lt: OTP_MAX_ATTEMPTS } }, + { $inc: { attempts: 1 } }, + { sort: { createdAt: -1 }, new: true } + ).lean(); + + if (!otp) { + const locked = await OtpCode.exists({ + email, + consumedAt: null, + expiresAt: { $gt: now }, + attempts: { $gte: OTP_MAX_ATTEMPTS }, + }); + if (locked) throw new HTTPException(400, { message: "Too many attempts. Please request a new code." }); + throw new HTTPException(400, { message: "Invalid or expired code" }); + } + + if (!constantTimeEqual(sha256(code), otp.codeHash)) { + throw new HTTPException(400, { message: "Invalid or expired code" }); + } + + const consumed = await OtpCode.updateOne({ _id: otp._id, consumedAt: null }, { $set: { consumedAt: now } }); + if (consumed.matchedCount === 0) throw new HTTPException(400, { message: "Invalid or expired code" }); +} diff --git a/backend/lib/participants.ts b/backend/lib/participants.ts index 1bf7f8eb..84cde5bd 100644 --- a/backend/lib/participants.ts +++ b/backend/lib/participants.ts @@ -1,15 +1,15 @@ -import { connect, Participant, Profile as ProfileModel } from "lib/db.js"; -import type { Participant as ParticipantT, Profile, ParticipantListMode } from "@birdplan/shared"; +import { connect, Participant, User as UserModel } from "lib/db.js"; +import type { Participant as ParticipantT, User, ParticipantListMode } from "@birdplan/shared"; -export async function isTripEditor(tripId: string, uid?: string | null): Promise { - if (!uid) return false; +export async function isTripEditor(tripId: string, userId?: string | null): Promise { + if (!userId) return false; await connect(); - return !!(await Participant.exists({ tripId, uid, status: "active" })); + return !!(await Participant.exists({ tripId, userId, status: "active" })); } -export function isEditorInRoster(roster: Pick[], uid?: string | null): boolean { - if (!uid) return false; - return roster.some((p) => p.uid === uid && p.status === "active"); +export function isEditorInRoster(roster: Pick[], userId?: string | null): boolean { + if (!userId) return false; + return roster.some((p) => p.userId === userId && p.status === "active"); } export async function loadActiveRoster(tripId: string): Promise { @@ -17,10 +17,10 @@ export async function loadActiveRoster(tripId: string): Promise return (await Participant.find({ tripId, status: "active" }).lean()) as unknown as ParticipantT[]; } -export async function loadProfilesByUid(roster: Pick[]): Promise> { - const uids = roster.map((p) => p.uid).filter((u): u is string => !!u); - const profiles = uids.length ? await ProfileModel.find({ uid: { $in: uids } }).lean() : []; - return new Map(profiles.map((p) => [p.uid, p as unknown as Profile] as const)); +export async function loadUsersById(roster: Pick[]): Promise> { + const userIds = roster.map((p) => p.userId).filter((u): u is string => !!u); + const users = userIds.length ? await UserModel.find({ _id: { $in: userIds } }).lean() : []; + return new Map(users.map((u) => [u._id, u as unknown as User] as const)); } export function computeIntersection(lists: string[][]): string[] { @@ -50,13 +50,13 @@ export function computeUnion(lists: string[][]): string[] { return result; } -type LeanParticipant = Pick; +type LeanParticipant = Pick; -export function participantEffectiveList(p: LeanParticipant, profilesByUid: Map): string[] { - if (!p.uid) return p.lifelist || []; - const profile = profilesByUid.get(p.uid); - const base = p.listMode === "custom" ? p.lifelist || [] : profile?.lifelist || []; - const exceptions = profile?.exceptions; +export function participantEffectiveList(p: LeanParticipant, usersById: Map): string[] { + if (!p.userId) return p.lifelist || []; + const user = usersById.get(p.userId); + const base = p.listMode === "custom" ? p.lifelist || [] : user?.lifelist || []; + const exceptions = user?.exceptions; if (!exceptions?.length) return base; const ex = new Set(exceptions); return base.filter((code) => !ex.has(code)); @@ -73,24 +73,24 @@ export type ResolvedTripLifelist = { export function resolveTripLifelist( activeParticipants: ParticipantT[], - profilesByUid: Map, - viewerUid?: string | null + usersById: Map, + viewerUserId?: string | null ): ResolvedTripLifelist { - const viewerP = viewerUid ? activeParticipants.find((p) => p.uid === viewerUid) : null; + const viewerP = viewerUserId ? activeParticipants.find((p) => p.userId === viewerUserId) : null; const viewer = viewerP ? { participantId: viewerP._id, listMode: viewerP.listMode, listUpdatedAt: viewerP.lifelistUpdatedAt ?? null } : null; - const viewerLifelist = viewerP ? participantEffectiveList(viewerP, profilesByUid) : null; + const viewerLifelist = viewerP ? participantEffectiveList(viewerP, usersById) : null; const isPublicViewer = !viewerP; if (activeParticipants.length <= 1) { const owner = activeParticipants[0]; - const tripLifelist = isPublicViewer && owner ? participantEffectiveList(owner, profilesByUid) : null; + const tripLifelist = isPublicViewer && owner ? participantEffectiveList(owner, usersById) : null; return { isGroup: false, groupLifelist: null, unionLifelist: null, tripLifelist, viewerLifelist, viewer }; } const lists = activeParticipants - .map((p) => participantEffectiveList(p, profilesByUid)) + .map((p) => participantEffectiveList(p, usersById)) .filter((list) => list.length > 0); const groupLifelist = computeIntersection(lists); const unionLifelist = computeUnion(lists); diff --git a/backend/lib/rateLimit.ts b/backend/lib/rateLimit.ts new file mode 100644 index 00000000..b15c962c --- /dev/null +++ b/backend/lib/rateLimit.ts @@ -0,0 +1,36 @@ +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 windowStartAt = new Date(Math.floor(now / rule.windowMs) * rule.windowMs); + + const doc = await RateLimitModel.findOneAndUpdate( + { ...scope, windowMs: rule.windowMs, windowStartAt }, + { + $inc: { count: 1 }, + $setOnInsert: { expiresAt: new Date(windowStartAt.getTime() + rule.windowMs) }, + }, + { upsert: true, new: true } + ).lean(); + + if (!doc) return true; + return doc.count <= rule.limit; +} + +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..3393a3c4 --- /dev/null +++ b/backend/lib/session.ts @@ -0,0 +1,86 @@ +import crypto from "crypto"; +import dayjs from "dayjs"; +import type { Session } from "@birdplan/shared"; +import { connect, User as UserModel, 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(userId: string, meta: SessionMeta = {}) { + await connect(); + 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), + userId, + lastActiveAt: now, + expiresAt, + userAgent: meta.userAgent, + ip: meta.ip, + }); + + await UserModel.updateOne({ _id: userId }, { $set: { lastAuthenticatedAt: now } }); + + 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; + + await connect(); + + 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 connect(); + await SessionModel.deleteOne({ _id: id }); +} + +export async function invalidateOtherSessions(userId: string, exceptSessionId: string) { + await connect(); + await SessionModel.deleteMany({ userId, _id: { $ne: exceptSessionId } }); +} diff --git a/backend/lib/users.ts b/backend/lib/users.ts new file mode 100644 index 00000000..f11e9518 --- /dev/null +++ b/backend/lib/users.ts @@ -0,0 +1,29 @@ +import { HTTPException } from "hono/http-exception"; +import type { User as UserType } from "@birdplan/shared"; +import { connect, User } from "lib/db.js"; +import { isDuplicateKeyError } from "lib/utils.js"; + +export const normalizeEmail = (email?: string | null) => email?.trim().toLowerCase() || ""; +export const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + +export async function findOrCreateUserByEmail(email: string): Promise<{ user: UserType; isNewUser: boolean }> { + await connect(); + + let user = await User.findOne({ email }).lean(); + let isNewUser = false; + if (!user) { + try { + user = (await User.create({ email })).toObject(); + isNewUser = true; + } catch (err) { + if (isDuplicateKeyError(err)) { + user = await User.findOne({ email }).lean(); + } else { + throw err; + } + } + } + if (!user) throw new HTTPException(500, { message: "Failed to create account" }); + + return { user, isNewUser }; +} diff --git a/backend/lib/utils.ts b/backend/lib/utils.ts index fa3281d0..c56a351c 100644 --- a/backend/lib/utils.ts +++ b/backend/lib/utils.ts @@ -1,47 +1,42 @@ 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"; +import { INVITE_EXPIRATION_DAYS } from "lib/config.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" }); - } +export const newInviteToken = () => ({ + inviteToken: nanoId(40), + inviteExpiresAt: new Date(Date.now() + INVITE_EXPIRATION_DAYS * 24 * 60 * 60 * 1000), +}); + +export const isDuplicateKeyError = (err: unknown): boolean => (err as { code?: number })?.code === 11000; +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/IntegrationToken.ts b/backend/models/IntegrationToken.ts new file mode 100644 index 00000000..afa9bcb8 --- /dev/null +++ b/backend/models/IntegrationToken.ts @@ -0,0 +1,22 @@ +import type { IntegrationToken } 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(32) }, + tripId: { type: String, required: true }, + type: { type: String, required: true, enum: ["openbirding"] }, + lastUsedAt: { type: Date, default: null }, +}; + +const IntegrationTokenSchema = new Schema(fields, { + timestamps: true, +}); + +IntegrationTokenSchema.index({ tripId: 1, createdAt: -1 }); + +const IntegrationTokenModel = + (mongoose.models.IntegrationToken as Model) || + model("IntegrationToken", IntegrationTokenSchema); + +export default IntegrationTokenModel; diff --git a/backend/models/Invite.ts b/backend/models/Invite.ts index 12e8e67f..014e3e08 100644 --- a/backend/models/Invite.ts +++ b/backend/models/Invite.ts @@ -9,7 +9,7 @@ const fields: Record, any> = { ownerId: { type: String, required: true }, accepted: { type: Boolean, required: true, default: false }, name: String, - uid: String, + userId: String, }; const InviteSchema = new Schema(fields, { @@ -17,7 +17,7 @@ const InviteSchema = new Schema(fields, { }); InviteSchema.index({ tripId: 1, createdAt: -1 }); // share modal -InviteSchema.index({ tripId: 1, uid: 1 }); // trip editors endpoint +InviteSchema.index({ tripId: 1, userId: 1 }); // trip editors endpoint const InviteModel = (mongoose.models.Invite as Model) || model("Invite", InviteSchema); diff --git a/backend/models/Log.ts b/backend/models/Log.ts new file mode 100644 index 00000000..bacb0240 --- /dev/null +++ b/backend/models/Log.ts @@ -0,0 +1,23 @@ +import type { Log } 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() }, + type: { type: String, required: true }, + email: { type: String, default: null }, + userId: { type: String, default: null }, + ip: { type: String, default: null }, + data: { type: Schema.Types.Mixed, default: null }, +}; + +const LogSchema = new Schema(fields, { + timestamps: true, +}); + +LogSchema.index({ type: 1, createdAt: -1 }); +LogSchema.index({ createdAt: -1 }); + +const LogModel = (mongoose.models.Log as Model) || model("Log", LogSchema); + +export default LogModel; diff --git a/backend/models/MagicLink.ts b/backend/models/MagicLink.ts new file mode 100644 index 00000000..4dae660f --- /dev/null +++ b/backend/models/MagicLink.ts @@ -0,0 +1,22 @@ +import type { MagicLink } 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() }, + tokenHash: { type: String, required: true, unique: true }, + userId: { type: String, required: true }, + expiresAt: { type: Date, required: true }, + consumedAt: { type: Date, default: null }, + createdByUserId: { type: String }, +}; + +const MagicLinkSchema = new Schema(fields, { + timestamps: true, +}); + +MagicLinkSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +const MagicLinkModel = (mongoose.models.MagicLink as Model) || model("MagicLink", MagicLinkSchema); + +export default MagicLinkModel; 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/Participant.ts b/backend/models/Participant.ts index 9bb9e684..ee8e0f9c 100644 --- a/backend/models/Participant.ts +++ b/backend/models/Participant.ts @@ -6,13 +6,15 @@ const fields: Record, any> = _id: { type: String, default: () => nanoId() }, tripId: { type: String, required: true }, status: { type: String, required: true, default: "active" }, - uid: String, + userId: String, email: String, name: String, listMode: { type: String, required: true, default: "world" }, lifelist: { type: [String], default: [] }, lifelistUpdatedAt: { type: Date, default: null }, isOwner: { type: Boolean, required: true, default: false }, + inviteToken: String, + inviteExpiresAt: { type: Date, default: null }, }; const ParticipantSchema = new Schema(fields, { @@ -20,9 +22,10 @@ const ParticipantSchema = new Schema(fields, { }); ParticipantSchema.index({ tripId: 1, createdAt: 1 }); -ParticipantSchema.index({ uid: 1 }); -ParticipantSchema.index({ tripId: 1, uid: 1 }, { unique: true, partialFilterExpression: { uid: { $exists: true } } }); +ParticipantSchema.index({ userId: 1 }); +ParticipantSchema.index({ tripId: 1, userId: 1 }, { unique: true, partialFilterExpression: { userId: { $exists: true } } }); ParticipantSchema.index({ tripId: 1, email: 1 }, { unique: true, partialFilterExpression: { email: { $exists: true } } }); +ParticipantSchema.index({ inviteToken: 1 }, { unique: true, sparse: true }); const ParticipantModel = (mongoose.models.Participant as Model) || model("Participant", ParticipantSchema); diff --git a/backend/models/RateLimit.ts b/backend/models/RateLimit.ts new file mode 100644 index 00000000..59008fec --- /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, windowStartAt: 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..fb2981fc --- /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 }, + userId: { 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({ userId: 1 }); +SessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +const SessionModel = (mongoose.models.Session as Model) || model("Session", SessionSchema); + +export default SessionModel; diff --git a/backend/models/TripShareToken.ts b/backend/models/TripShareToken.ts deleted file mode 100644 index 74aad4e5..00000000 --- a/backend/models/TripShareToken.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { TripShareToken } 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(32) }, - tripId: { type: String, required: true }, - type: { type: String, required: true, enum: ["openbirding"] }, - lastUsedAt: { type: Date, default: null }, -}; - -const TripShareTokenSchema = new Schema(fields, { - timestamps: true, -}); - -TripShareTokenSchema.index({ tripId: 1, createdAt: -1 }); - -const TripShareTokenModel = - (mongoose.models.TripShareToken as Model) || - model("TripShareToken", TripShareTokenSchema); - -export default TripShareTokenModel; diff --git a/backend/models/Profile.ts b/backend/models/User.ts similarity index 52% rename from backend/models/Profile.ts rename to backend/models/User.ts index 791811c5..dd9a7e40 100644 --- a/backend/models/Profile.ts +++ b/backend/models/User.ts @@ -1,27 +1,27 @@ -import type { Profile } from "@birdplan/shared"; +import type { User } from "@birdplan/shared"; import mongoose, { Schema, model, Model } from "mongoose"; import { nanoId } from "lib/utils.js"; -const fields: Record, any> = { +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, + lastAuthenticatedAt: { type: Date, default: null }, isAdmin: { type: Boolean, default: false }, }; -const ProfileSchema = new Schema(fields, { +const UserSchema = new Schema(fields, { timestamps: true, }); -const ProfileModel = (mongoose.models.Profile as Model) || model("Profile", ProfileSchema); +UserSchema.index({ email: 1 }, { unique: true }); -export default ProfileModel; +const UserModel = (mongoose.models.User as Model) || model("User", UserSchema); + +export default UserModel; diff --git a/backend/package.json b/backend/package.json index 3bd78fac..619924d2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,7 +18,7 @@ "hono-rate-limiter": "^0.5.3", "mongoose": "^8.15.1", "nanoid": "^5.1.5", - "resend": "^4.5.2" + "resend": "^6.14.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/backend/routes/account.ts b/backend/routes/account.ts index e72c2c57..50ffea6b 100644 --- a/backend/routes/account.ts +++ b/backend/routes/account.ts @@ -1,51 +1,93 @@ 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 { authenticate, isDuplicateKeyError } from "lib/utils.js"; +import { connect, User, Trip, Participant, Session, OtpCode } from "lib/db.js"; +import { issueOtp, verifyOtp } from "lib/otp.js"; +import { invalidateOtherSessions } from "lib/session.js"; +import { enforceRateLimit } from "lib/rateLimit.js"; +import { RATE_LIMITS } from "lib/config.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); +const getIp = (c: { req: { header: (name: string) => string | undefined } }) => + c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; + account.delete("/", async (c) => { const session = await authenticate(c); - - const uid = session.uid; + const userId = session.userId; await connect(); - const tripIds = await Trip.distinct("_id", { ownerId: uid }); + const user = await User.findOne({ _id: userId }).select("email").lean(); + const tripIds = await Trip.distinct("_id", { ownerId: userId }); await Promise.all([ - Profile.deleteOne({ uid }), - Participant.deleteMany({ uid }), + User.deleteOne({ _id: userId }), + Participant.deleteMany({ userId }), Participant.deleteMany({ tripId: { $in: tripIds } }), - Trip.deleteMany({ ownerId: uid }), + Trip.deleteMany({ ownerId: userId }), + Session.deleteMany({ userId }), + user?.email ? OtpCode.deleteMany({ email: user.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); + const ip = getIp(c); + if (!email || !isValidEmail(email)) throw new HTTPException(400, { message: "A valid email is required" }); + + await connect(); - const user = await firebaseAuth?.getUser(session.uid); - if (!user) throw new HTTPException(404, { message: "User not found" }); + const emailOk = await enforceRateLimit("request_code", "email", email, RATE_LIMITS.requestCodeEmail); + const ipOk = await enforceRateLimit("request_code", "ip", ip, RATE_LIMITS.requestCodeIp); + const userOk = await enforceRateLimit("request_code", "userId", session.userId, RATE_LIMITS.requestCodeIp); + if (!emailOk || !ipOk || !userOk) throw new HTTPException(429, { message: "Too many requests. Please try again later." }); - 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 User.findOne({ email }).select("_id").lean(); + if (existing && existing._id !== session.userId) { + 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, ip); + + 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); + const ip = getIp(c); + if (!email || !code) throw new HTTPException(400, { message: "Email and code are required" }); + + 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." }); + + const existing = await User.findOne({ email }).select("_id").lean(); + if (existing && existing._id !== session.userId) { + throw new HTTPException(400, { message: "That email is already in use" }); + } + + await verifyOtp(email, code); + + try { + await User.updateOne({ _id: session.userId }, { $set: { email } }); + } catch (err) { + if (isDuplicateKeyError(err)) throw new HTTPException(400, { message: "That email is already in use" }); + throw err; + } + + await invalidateOtherSessions(session.userId, session._id); + return c.json({ message: "Email updated successfully" }); }); diff --git a/backend/routes/admin.ts b/backend/routes/admin.ts index f05c02e2..73ea0448 100644 --- a/backend/routes/admin.ts +++ b/backend/routes/admin.ts @@ -1,70 +1,82 @@ import { Hono } from "hono"; +import type { Context } 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"; +import { connect, User, Trip, Log } from "lib/db.js"; +import { issueMagicLink } from "lib/magicLink.js"; +import { findOrCreateUserByEmail, normalizeEmail, isValidEmail } from "lib/users.js"; +import { logEvent } from "lib/log.js"; +import type { AdminDashboard, AdminDashboardUser, AdminDashboardLog, GenerateMagicLinkResponse, User as UserType } 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 getIp = (c: Context) => c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; - 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 requireAdmin = async (c: Context) => { const session = await authenticate(c); - await connect(); - - const requester = await Profile.findOne({ uid: session.uid }).select("isAdmin").lean(); + const requester = await User.findOne({ _id: session.userId }).select("isAdmin email").lean(); if (!requester?.isAdmin) throw new HTTPException(403, { message: "Forbidden" }); + return requester; +}; + +const buildMagicLink = async ( + c: Context, + user: Pick, + admin: Pick, + extra?: Record +): Promise => { + const { token, expiresAt } = await issueMagicLink(user._id, admin._id); + const url = `${process.env.FRONTEND_URL}/magic/${token}`; + + await logEvent({ + type: "magic_link_generated", + userId: user._id, + email: user.email, + ip: getIp(c), + data: { byUserId: admin._id, byEmail: admin.email, ...extra }, + }); + + return { url, expiresAt: expiresAt.toISOString(), email: user.email }; +}; + +admin.get("/", async (c) => { + await requireAdmin(c); const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const sixMonthsAgo = new Date(); sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); - const [totalUsers, activeUsers30d, activeUsers6mo, totalTrips, trips30d, trips6mo, profiles] = await Promise.all([ - Profile.countDocuments({}), - Profile.countDocuments({ lastActiveAt: { $gte: thirtyDaysAgo } }), - Profile.countDocuments({ lastActiveAt: { $gte: sixMonthsAgo } }), + const [totalUsers, activeUsers30d, activeUsers6mo, totalTrips, trips30d, trips6mo, userDocs, logDocs] = await Promise.all([ + User.countDocuments({}), + User.countDocuments({ lastActiveAt: { $gte: thirtyDaysAgo } }), + User.countDocuments({ lastActiveAt: { $gte: sixMonthsAgo } }), Trip.countDocuments({}), Trip.countDocuments({ createdAt: { $gte: thirtyDaysAgo } }), Trip.countDocuments({ createdAt: { $gte: sixMonthsAgo } }), - Profile.find({}).select("uid name email photoUrl lastActiveAt createdAt").lean(), + User.find({}).select("name email photoUrl lastActiveAt lastAuthenticatedAt createdAt").lean(), + Log.find({}).sort({ createdAt: -1 }).limit(200).lean(), ]); - const providersByUid = await getProvidersByUid(profiles.map((profile) => profile.uid)); - - const users: AdminDashboardUser[] = profiles.map((profile) => ({ - _id: profile._id, - uid: profile.uid, - name: profile.name, - email: profile.email, - photoUrl: profile.photoUrl, - createdAt: (profile as unknown as { createdAt: Date }).createdAt?.toISOString?.() ?? "", - lastActiveAt: profile.lastActiveAt ?? null, - providers: providersByUid.get(profile.uid) ?? [], + const users: AdminDashboardUser[] = userDocs.map((user) => ({ + _id: user._id, + name: user.name, + email: user.email, + photoUrl: user.photoUrl, + createdAt: (user as unknown as { createdAt: Date }).createdAt?.toISOString?.() ?? "", + lastActiveAt: user.lastActiveAt ?? null, + lastAuthenticatedAt: user.lastAuthenticatedAt ?? null, + })); + + const logs: AdminDashboardLog[] = logDocs.map((log) => ({ + _id: log._id, + type: log.type, + email: log.email ?? null, + userId: log.userId ?? null, + ip: log.ip ?? null, + data: log.data ?? null, + createdAt: log.createdAt?.toISOString() ?? "", })); const response: AdminDashboard = { @@ -73,9 +85,33 @@ admin.get("/", async (c) => { trips: { total: totalTrips, created30d: trips30d, created6mo: trips6mo }, }, users, + logs, }; return c.json(response); }); +admin.post("/users/:userId/magic-link", async (c) => { + const requester = await requireAdmin(c); + const userId = c.req.param("userId"); + + const user = await User.findOne({ _id: userId }).select("email").lean(); + if (!user) throw new HTTPException(404, { message: "User not found" }); + + return c.json(await buildMagicLink(c, user, requester)); +}); + +admin.post("/magic-link", async (c) => { + const requester = await requireAdmin(c); + const { email: rawEmail } = await c.req.json<{ email: string }>(); + const email = normalizeEmail(rawEmail); + + if (!email || !isValidEmail(email)) throw new HTTPException(400, { message: "A valid email is required" }); + + const { user, isNewUser } = await findOrCreateUserByEmail(email); + + const link = await buildMagicLink(c, user, requester, { isNewUser }); + return c.json({ ...link, isNewUser }); +}); + export default admin; diff --git a/backend/routes/auth.ts b/backend/routes/auth.ts index b5ec713b..6e6e727e 100644 --- a/backend/routes/auth.ts +++ b/backend/routes/auth.ts @@ -1,83 +1,132 @@ 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, User, Session } from "lib/db.js"; +import { authenticate } from "lib/utils.js"; +import { findOrCreateUserByEmail, normalizeEmail, isValidEmail } from "lib/users.js"; +import { createSession, invalidateSession } from "lib/session.js"; +import { issueOtp, verifyOtp } from "lib/otp.js"; +import { redeemMagicLink } from "lib/magicLink.js"; +import { enforceRateLimit } from "lib/rateLimit.js"; +import { logEvent } from "lib/log.js"; +import { sendNtfyNotification } from "lib/notify.js"; +import { SESSION_INACTIVITY_DAYS, SESSION_REFRESH_THRESHOLD_HOURS, RATE_LIMITS } from "lib/config.js"; +import type { RedeemMagicLinkResponse } from "@birdplan/shared"; 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 getIp = (c: { req: { header: (name: string) => string | undefined } }) => + c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; + +auth.post("/request-code", async (c) => { + const { email: rawEmail } = await c.req.json<{ email: string }>(); + const email = normalizeEmail(rawEmail); + const ip = getIp(c); + + if (!email || !isValidEmail(email)) return c.json({ ok: true }); await connect(); - const user = await firebaseAuth?.getUserByEmail(email); - if (!user || !user.providerData.some((provider) => provider.providerId === "password")) { - console.log("User not found/invalid provider", user?.providerData); - return Response.json({}); - } + 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 resetToken = nanoId(64); - const resetTokenExpires = dayjs().add(RESET_TOKEN_EXPIRATION, "hours").toDate(); - const url = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`; + await issueOtp(email, ip); - await Profile.updateOne({ uid: user.uid }, { resetToken, resetTokenExpires }); - await sendResetEmail({ email, url }); - return c.json({}); + return c.json({ ok: true }); }); -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" }); +auth.post("/otp-not-received", async (c) => { + const { email: rawEmail } = await c.req.json<{ email: string }>(); + const email = normalizeEmail(rawEmail); + const ip = getIp(c); + + 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("otp_not_received", "email", email, RATE_LIMITS.otpNotReceivedEmail); + const ipOk = await enforceRateLimit("otp_not_received", "ip", ip, RATE_LIMITS.otpNotReceivedIp); + if (!emailOk || !ipOk) return c.json({ ok: true }); - const user = await firebaseAuth?.getUser(profile.uid); + await logEvent({ type: "otp_not_received", email, ip }); + await sendNtfyNotification("šŸ“­ OTP not received", `${email} reported not receiving their sign-in code.`); - 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 } = await c.req.json<{ email: string; code: string }>(); + const email = normalizeEmail(rawEmail); + const ip = getIp(c); - if (!profile.resetTokenExpires || dayjs().isAfter(dayjs(profile.resetTokenExpires))) { - throw new HTTPException(400, { message: "Reset token has expired" }); - } + if (!email || !code) throw new HTTPException(400, { message: "Email and code are required" }); + + 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 firebaseAuth?.updateUser(user.uid, { password }); + await verifyOtp(email, code); - await Profile.updateOne({ uid: user.uid }, { $unset: { resetToken: "", resetTokenExpires: "" } }); + const { user, isNewUser } = await findOrCreateUserByEmail(email); - return c.json({ message: "Password reset successfully" }); + const { token } = await createSession(user._id, { + userAgent: c.req.header("user-agent"), + ip, + }); + + return c.json({ token, isNewUser }); }); -auth.get("/verify-reset-token", async (c) => { - const { searchParams } = new URL(c.req.url); - const token = searchParams.get("token"); +auth.post("/redeem-magic-link", async (c) => { + const { token } = await c.req.json<{ token: string }>(); + const ip = getIp(c); + + if (!token) throw new HTTPException(400, { message: "Missing token" }); + + await connect(); + + const ipOk = await enforceRateLimit("redeem_magic_link", "ip", ip, RATE_LIMITS.redeemMagicLinkIp); + if (!ipOk) throw new HTTPException(429, { message: "Too many requests. Please try again later." }); + + const { sessionToken, userId } = await redeemMagicLink(token, { + userAgent: c.req.header("user-agent"), + ip, + }); + + await logEvent({ type: "magic_link_redeemed", userId, ip }); - if (!token) throw new HTTPException(400, { message: "Token is required" }); + return c.json({ token: sessionToken }); +}); + +auth.get("/me", async (c) => { + const session = await authenticate(c); await connect(); - const profile = await Profile.findOne({ resetToken: token }).lean(); + const user = await User.findOne({ _id: session.userId }).lean(); + if (!user) { + 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 User.updateOne({ _id: session.userId }, { $set: { lastActiveAt: nowDate } }); } - return c.json({ isValid: true }); + return c.json(user); +}); + +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/support.ts b/backend/routes/contact.ts similarity index 88% rename from backend/routes/support.ts rename to backend/routes/contact.ts index 7fefa44c..1a49cd58 100644 --- a/backend/routes/support.ts +++ b/backend/routes/contact.ts @@ -2,9 +2,9 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { sendEmail } from "lib/email.js"; -const support = new Hono(); +const contact = new Hono(); -type SupportRequest = { +type ContactRequest = { name: string; email: string; type: string; @@ -17,8 +17,8 @@ type SupportRequest = { }; }; -support.post("/", async (c) => { - const data = await c.req.json(); +contact.post("/", async (c) => { + const data = await c.req.json(); const { name, email, type, message, browserInfo } = data; if (!name || !email || !type || !message) { @@ -51,4 +51,4 @@ support.post("/", async (c) => { return c.json({ success: true }); }); -export default support; +export default contact; diff --git a/backend/routes/participants.ts b/backend/routes/participants.ts index c798bf46..c0e1a41f 100644 --- a/backend/routes/participants.ts +++ b/backend/routes/participants.ts @@ -1,79 +1,125 @@ 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"; +import { connect, Participant, User, Trip } from "lib/db.js"; +import { authenticateOptional, isDuplicateKeyError } from "lib/utils.js"; +import { createSession } from "lib/session.js"; +import type { AcceptInviteResponse, InviteInfo } from "@birdplan/shared"; const participants = new Hono(); -participants.get("/:id/invite", async (c) => { - const id: string = c.req.param("id"); +const normalizeEmail = (email?: string | null) => email?.trim().toLowerCase() || ""; +const getIp = (c: { req: { header: (name: string) => string | undefined } }) => + c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; + +participants.get("/:token/invite", async (c) => { + const token: string = c.req.param("token"); await connect(); - const invite = await Participant.findById(id).lean(); - if (!invite) throw new HTTPException(404, { message: "This invite no longer exists." }); + const invite = await Participant.findOne({ inviteToken: token }).lean(); + if (!invite) throw new HTTPException(404, { message: "This invite link is no longer valid. It may have already been used." }); + if (invite.inviteExpiresAt && invite.inviteExpiresAt.getTime() < Date.now()) { + throw new HTTPException(410, { message: "This invite has expired. Ask the trip owner to send you a new one." }); + } 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 email = normalizeEmail(invite.email); + const accountExists = !!email && !!(await User.exists({ email })); const info: InviteInfo = { tripId: invite.tripId, tripName: trip.name, inviterName: trip.ownerName, - email: invite.status === "pending" ? invite.email : undefined, - method: accountExists ? "login" : "signup", + email: invite.email, status: invite.status, + accountExists, }; return c.json(info); }); -participants.patch("/:id/accept", async (c) => { - const session = await authenticate(c); - if (!session?.uid) throw new HTTPException(401, { message: "Unauthorized" }); - - const id: string = c.req.param("id"); +participants.post("/:token/accept", async (c) => { + const token: string = c.req.param("token"); + const session = await authenticateOptional(c); await connect(); - const pending = await Participant.findById(id).lean(); - if (!pending) throw new HTTPException(404, { message: "This invite no longer exists." }); + const pending = await Participant.findOne({ inviteToken: token }).lean(); + if (!pending) throw new HTTPException(410, { message: "This invite link is no longer valid. It may have already been used." }); - const existing = await Participant.findOne({ tripId: pending.tripId, uid: session.uid, status: "active" }).lean(); - if (existing) { - if (existing._id !== pending._id) await Participant.deleteOne({ _id: pending._id }); - return c.json({ tripId: pending.tripId }); + if (pending.inviteExpiresAt && pending.inviteExpiresAt.getTime() < Date.now()) { + throw new HTTPException(410, { message: "This invite has expired. Ask the trip owner to send you a new one." }); } - if (pending.uid && pending.uid !== session.uid) { - throw new HTTPException(409, { message: "This invite has already been accepted." }); + let user = session + ? await User.findOne({ _id: session.userId }).lean() + : await User.findOne({ email: normalizeEmail(pending.email) }).lean(); + + if (session) { + if (!user) throw new HTTPException(401, { message: "Your session is no longer valid." }); + } else { + const invitedEmail = normalizeEmail(pending.email); + if (!invitedEmail) throw new HTTPException(400, { message: "This invite cannot be accepted." }); + if (!user) { + try { + user = (await User.create({ email: invitedEmail })).toObject(); + } catch (err) { + if (isDuplicateKeyError(err)) { + user = await User.findOne({ email: invitedEmail }).lean(); + } else { + throw err; + } + } + } + if (!user) throw new HTTPException(500, { message: "Failed to accept this invite." }); } - const profile = await Profile.findOne({ uid: session.uid }).lean(); - const name = profile?.name || session.name || (await auth?.getUser(session.uid))?.displayName || pending.name; - - const hasCuratedList = !!pending.lifelist?.length; - - let result; - try { - result = await Participant.updateOne( - { _id: id, status: "pending", uid: { $exists: false } }, - { $set: { status: "active", uid: session.uid, name, ...(hasCuratedList ? {} : { listMode: "world" }) } } - ); - } catch (err) { - if ((err as { code?: number })?.code === 11000) { - await Participant.deleteOne({ _id: id, status: "pending" }); - return c.json({ tripId: pending.tripId }); + const name = user.name || pending.name; + + const existing = await Participant.findOne({ tripId: pending.tripId, userId: user._id, status: "active" }).lean(); + if (existing) { + if (existing._id !== pending._id) await Participant.deleteOne({ _id: pending._id }); + } else { + let matched = true; + try { + const result = await Participant.updateOne( + { _id: pending._id, status: "pending", userId: { $exists: false }, inviteToken: token }, + { + $set: { + status: "active", + userId: user._id, + ...(name ? { name } : {}), + }, + $unset: { inviteToken: "", inviteExpiresAt: "", email: "" }, + } + ); + matched = result.matchedCount > 0; + } catch (err) { + if (isDuplicateKeyError(err)) { + await Participant.deleteOne({ _id: pending._id, status: "pending" }); + } else { + throw err; + } + } + if (!matched) { + const reread = await Participant.findById(pending._id).lean(); + if (reread?.userId !== user._id) throw new HTTPException(409, { message: "This invite has already been accepted." }); } - throw err; } - if (result.matchedCount === 0) { - throw new HTTPException(409, { message: "This invite has already been accepted." }); + + let sessionToken: string | undefined; + if (!session) { + ({ token: sessionToken } = await createSession(user._id, { userAgent: c.req.header("user-agent"), ip: getIp(c) })); } - return c.json({ tripId: pending.tripId }); + const response: AcceptInviteResponse = { + tripId: pending.tripId, + token: sessionToken, + hasName: !!user.name, + hasLifelist: !!user.lifelist?.length, + }; + + return c.json(response); }); export default participants; diff --git a/backend/routes/profile.ts b/backend/routes/profile.ts index 9d3adeb0..cec0960d 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, User, 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,14 +19,23 @@ 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]; } }); - await Profile.updateOne({ uid: session.uid }, data); + if (typeof data.name === "string") { + data.name = data.name.trim(); + if (!data.name) delete data.name; + } + + await User.updateOne({ _id: session.userId }, data); + + if (data.name) { + await Participant.updateMany({ userId: session.userId }, { $set: { name: data.name } }); + } return c.json({}); }); @@ -75,7 +50,7 @@ profile.put("/lifelist", async (c) => { const codes = await sciNamesToCodes(sciNames); - await Profile.updateOne({ uid: session.uid }, { $set: { lifelist: codes, lifelistUpdatedAt: new Date() } }); + await User.updateOne({ _id: session.userId }, { $set: { lifelist: codes, lifelistUpdatedAt: new Date() } }); return c.json({}); }); @@ -88,8 +63,8 @@ profile.post("/lifelist/add", async (c) => { const { code } = await c.req.json(); if (!code) throw new HTTPException(400, { message: "Missing code" }); - await Profile.updateOne( - { uid: session.uid }, + await User.updateOne( + { _id: session.userId }, { $addToSet: { lifelist: code }, $pull: { exceptions: code }, $set: { lifelistUpdatedAt: new Date() } }, ); diff --git a/backend/routes/trips/[tripId]/hotspots.ts b/backend/routes/trips/[tripId]/hotspots.ts index f2a9e7ba..80c9d288 100644 --- a/backend/routes/trips/[tripId]/hotspots.ts +++ b/backend/routes/trips/[tripId]/hotspots.ts @@ -20,7 +20,7 @@ hotspots.post("/", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -42,7 +42,7 @@ hotspots.delete("/:hotspotId", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -62,7 +62,7 @@ hotspots.patch("/:hotspotId/translate-name", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -104,7 +104,7 @@ hotspots.post("/:hotspotId/add-species-fav", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -133,7 +133,7 @@ hotspots.patch("/sync", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -174,7 +174,7 @@ hotspots.patch("/:hotspotId/notes", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -230,7 +230,7 @@ hotspots.patch("/:hotspotId/remove-species-fav", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -257,7 +257,7 @@ hotspots.patch("/:hotspotId/reset-name", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); diff --git a/backend/routes/trips/[tripId]/index.ts b/backend/routes/trips/[tripId]/index.ts index acce6b27..2d929fc0 100644 --- a/backend/routes/trips/[tripId]/index.ts +++ b/backend/routes/trips/[tripId]/index.ts @@ -9,9 +9,10 @@ import { computeFrequency, generateOpenBirdingCode, getBounds, + isDuplicateKeyError, } from "lib/utils.js"; -import { connect, Trip, Invite, Participant, Profile, TripShareToken } from "lib/db.js"; -import { isTripEditor, isEditorInRoster, loadActiveRoster, loadProfilesByUid, resolveTripLifelist } from "lib/participants.js"; +import { connect, Trip, Invite, Participant, User, IntegrationToken } from "lib/db.js"; +import { isTripEditor, isEditorInRoster, loadActiveRoster, loadUsersById, resolveTripLifelist } from "lib/participants.js"; import { uploadMapboxImageToStorage } from "lib/firebaseAdmin.js"; import { OPENBIRDING_API_URL, SHARE_CODE_TTL_MINUTES } from "lib/config.js"; import type { TripUpdateInput, OpenBirdingLocationResponse } from "@birdplan/shared"; @@ -45,12 +46,12 @@ trip.get("/", async (c) => { throw new HTTPException(404, { message: "Trip not found" }); } - if (!trip.isPublic && !isEditorInRoster(roster, session?.uid)) { + if (!trip.isPublic && !isEditorInRoster(roster, session?.userId)) { throw new HTTPException(403, { message: "Forbidden" }); } - const profilesByUid = await loadProfilesByUid(roster); - const resolved = resolveTripLifelist(roster, profilesByUid, session?.uid); + const usersById = await loadUsersById(roster); + const resolved = resolveTripLifelist(roster, usersById, session?.userId); const { shareCode, shareCodeCreatedAt, ...tripData } = trip; return c.json({ ...tripData, @@ -80,7 +81,7 @@ trip.patch("/", async (c) => { if (!trip) { throw new HTTPException(404, { message: "Trip not found" }); } - if (!(await isTripEditor(tripId, session.uid))) { + if (!(await isTripEditor(tripId, session.userId))) { throw new HTTPException(403, { message: "Forbidden" }); } @@ -123,7 +124,7 @@ trip.delete("/", async (c) => { if (!trip) { throw new HTTPException(404, { message: "Trip not found" }); } - if (trip.ownerId !== session.uid) { + if (trip.ownerId !== session.userId) { throw new HTTPException(403, { message: "Forbidden" }); } @@ -131,7 +132,7 @@ trip.delete("/", async (c) => { Trip.deleteOne({ _id: tripId }), Participant.deleteMany({ tripId }), Invite.deleteMany({ tripId }), - TripShareToken.deleteMany({ tripId }), + IntegrationToken.deleteMany({ tripId }), ]); return c.json({}); @@ -139,7 +140,7 @@ trip.delete("/", async (c) => { trip.get("/export", async (c) => { const tripId: string | undefined = c.req.param("tripId"); - const uid: string | undefined = c.req.query("uid"); + const userId: string | undefined = c.req.query("userId"); const targets: string | undefined = c.req.query("targets"); if (!tripId) { @@ -153,14 +154,14 @@ trip.get("/export", async (c) => { throw new HTTPException(404, { message: "Trip not found" }); } - const profilesByUid = await loadProfilesByUid(roster); - const resolved = resolveTripLifelist(roster, profilesByUid, uid); + const usersById = await loadUsersById(roster); + const resolved = resolveTripLifelist(roster, usersById, userId); const lifelist = (targets === "personal" ? resolved.viewerLifelist : null) ?? resolved.groupLifelist ?? resolved.tripLifelist ?? resolved.viewerLifelist ?? - (uid ? (await Profile.findOne({ uid }).lean())?.lifelist : null) ?? + (userId ? (await User.findOne({ _id: userId }).lean())?.lifelist : null) ?? []; const months = getMonthRange(trip.startMonth, trip.endMonth); @@ -220,7 +221,7 @@ trip.post("/share-code", async (c) => { if (!existing) { throw new HTTPException(404, { message: "Trip not found" }); } - if (!(await isTripEditor(tripId, session.uid))) { + if (!(await isTripEditor(tripId, session.userId))) { throw new HTTPException(403, { message: "Forbidden" }); } @@ -249,7 +250,7 @@ trip.post("/share-code", async (c) => { const expiresAt = new Date(savedAt + SHARE_CODE_TTL_MINUTES * 60 * 1000); return c.json({ shareCode: savedCode, expiresAt: expiresAt.toISOString() }); } catch (error: any) { - const isDuplicateCode = error?.code === 11000 && error?.keyPattern?.shareCode; + const isDuplicateCode = isDuplicateKeyError(error) && error?.keyPattern?.shareCode; if (!isDuplicateCode || attempt === maxRetries - 1) throw error; } } @@ -272,7 +273,7 @@ trip.patch("/set-start-date", async (c) => { if (!trip) { throw new HTTPException(404, { message: "Trip not found" }); } - if (!(await isTripEditor(tripId, session.uid))) { + if (!(await isTripEditor(tripId, session.userId))) { throw new HTTPException(403, { message: "Forbidden" }); } diff --git a/backend/routes/trips/[tripId]/itinerary.ts b/backend/routes/trips/[tripId]/itinerary.ts index 5669a73d..7a8d6d0f 100644 --- a/backend/routes/trips/[tripId]/itinerary.ts +++ b/backend/routes/trips/[tripId]/itinerary.ts @@ -25,7 +25,7 @@ itinerary.post("/", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -47,7 +47,7 @@ itinerary.delete("/:dayId", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -70,7 +70,7 @@ itinerary.patch("/:dayId/move-location", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -110,7 +110,7 @@ itinerary.patch("/:dayId/notes", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -133,7 +133,7 @@ itinerary.patch("/:dayId/remove-location", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -174,7 +174,7 @@ itinerary.patch("/:dayId/remove-travel-time", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -210,7 +210,7 @@ itinerary.patch("/:dayId/set-notes", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -233,7 +233,7 @@ itinerary.post("/:dayId/add-location", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -274,7 +274,7 @@ itinerary.patch("/:dayId/calc-travel-time", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); diff --git a/backend/routes/trips/[tripId]/markers.ts b/backend/routes/trips/[tripId]/markers.ts index 02cc5818..34d62ac5 100644 --- a/backend/routes/trips/[tripId]/markers.ts +++ b/backend/routes/trips/[tripId]/markers.ts @@ -17,7 +17,7 @@ markers.post("/", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -39,7 +39,7 @@ markers.delete("/:markerId", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -62,7 +62,7 @@ markers.patch("/:markerId/notes", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -85,7 +85,7 @@ markers.patch("/:markerId", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); diff --git a/backend/routes/trips/[tripId]/participants.ts b/backend/routes/trips/[tripId]/participants.ts index 634a8950..13864e02 100644 --- a/backend/routes/trips/[tripId]/participants.ts +++ b/backend/routes/trips/[tripId]/participants.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; -import { authenticate } from "lib/utils.js"; -import { connect, Trip, Participant, Profile } from "lib/db.js"; +import { authenticate, newInviteToken, isDuplicateKeyError } from "lib/utils.js"; +import { connect, Trip, Participant, User } from "lib/db.js"; import { isTripEditor, isEditorInRoster, participantEffectiveList } from "lib/participants.js"; import { sciNamesToCodes } from "lib/taxonomy.js"; import { sendInviteEmail } from "lib/email.js"; @@ -28,24 +28,24 @@ participants.get("/", async (c) => { if (!trip) throw new HTTPException(404, { message: "Trip not found" }); const roster = await Participant.find({ tripId }).sort({ createdAt: 1 }).lean(); - const isEditor = isEditorInRoster(roster, session?.uid); + const isEditor = isEditorInRoster(roster, session?.userId); if (!trip.isPublic && !isEditor) throw new HTTPException(403, { message: "Forbidden" }); - const uids = roster.map((p) => p.uid).filter((u): u is string => !!u); - const profiles = uids.length ? await Profile.find({ uid: { $in: uids } }).lean() : []; - const profilesByUid = new Map(profiles.map((p) => [p.uid, p])); + const userIds = roster.map((p) => p.userId).filter((u): u is string => !!u); + const users = userIds.length ? await User.find({ _id: { $in: userIds } }).lean() : []; + const usersById = new Map(users.map((u) => [u._id, u])); const views: ParticipantView[] = roster.map((p) => ({ _id: p._id, - uid: p.uid, + userId: p.userId, name: p.name, - photoUrl: p.uid ? profilesByUid.get(p.uid)?.photoUrl : undefined, - ...(isEditor ? { email: p.email } : {}), + photoUrl: p.userId ? usersById.get(p.userId)?.photoUrl : undefined, + ...(isEditor ? { email: p.userId ? usersById.get(p.userId)?.email : p.email } : {}), status: p.status, listMode: p.listMode, isOwner: p.isOwner, - isMe: !!session?.uid && p.uid === session.uid, - count: participantEffectiveList(p, profilesByUid).length, + isMe: !!session?.userId && p.userId === session.userId, + count: participantEffectiveList(p, usersById).length, hasList: (p.status === "active" && p.listMode === "world") || !!p.lifelistUpdatedAt, })); @@ -60,11 +60,14 @@ participants.post("/", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); + const inviter = await User.findOne({ _id: session.userId }).select("name").lean(); + const inviterName = inviter?.name || ""; + const body = await c.req.json(); if (body.type === "named") { @@ -85,26 +88,32 @@ participants.post("/", async (c) => { const email = body.email?.trim().toLowerCase(); if (!email) throw new HTTPException(400, { message: "Email is required" }); - const dupEmail = await Participant.exists({ tripId, email }); + const invitedUser = await User.findOne({ email }).select("_id").lean(); + const dupEmail = await Participant.exists({ + tripId, + $or: [{ email }, ...(invitedUser ? [{ userId: invitedUser._id }] : [])], + }); if (dupEmail) throw new HTTPException(400, { message: alreadyAddedMessage }); + const invite = newInviteToken(); + if (body.upgradeId) { const named = await Participant.findOne({ _id: body.upgradeId, tripId }).lean(); - if (!named || named.uid) throw new HTTPException(400, { message: "Cannot upgrade this participant" }); + if (!named || named.userId) throw new HTTPException(400, { message: "Cannot upgrade this participant" }); try { await Participant.updateOne( { _id: body.upgradeId }, - { $set: { email, status: "pending", listMode: named.lifelist?.length ? "custom" : "world" } } + { $set: { email, status: "pending", listMode: named.lifelist?.length ? "custom" : "world", ...invite } } ); } catch (err) { - if ((err as { code?: number })?.code === 11000) throw new HTTPException(400, { message: alreadyAddedMessage }); + if (isDuplicateKeyError(err)) throw new HTTPException(400, { message: alreadyAddedMessage }); throw err; } await sendInviteEmail({ tripName: trip.name, - fromName: session.name || "", + fromName: inviterName, email, - url: `${process.env.FRONTEND_URL}/accept/${body.upgradeId}`, + url: `${process.env.FRONTEND_URL}/accept/${invite.inviteToken}`, }); return c.json({ id: body.upgradeId }); } @@ -120,17 +129,18 @@ participants.post("/", async (c) => { lifelist: codes, lifelistUpdatedAt: codes.length ? new Date() : null, isOwner: false, + ...invite, }); } catch (err) { - if ((err as { code?: number })?.code === 11000) throw new HTTPException(400, { message: alreadyAddedMessage }); + if (isDuplicateKeyError(err)) throw new HTTPException(400, { message: alreadyAddedMessage }); throw err; } await sendInviteEmail({ tripName: trip.name, - fromName: session.name || "", + fromName: inviterName, email, - url: `${process.env.FRONTEND_URL}/accept/${participant._id}`, + url: `${process.env.FRONTEND_URL}/accept/${invite.inviteToken}`, }); return c.json({ id: participant._id }); @@ -143,11 +153,11 @@ participants.patch("/:id", async (c) => { if (!tripId || !id) throw new HTTPException(400, { message: "Trip ID and participant ID are required" }); await connect(); - if (!(await isTripEditor(tripId, session.uid))) throw new HTTPException(403, { message: "Forbidden" }); + if (!(await isTripEditor(tripId, session.userId))) throw new HTTPException(403, { message: "Forbidden" }); const p = await Participant.findOne({ _id: id, tripId }).lean(); if (!p) throw new HTTPException(404, { message: "Participant not found" }); - if (p.uid) throw new HTTPException(400, { message: "Can't rename a registered user" }); + if (p.userId) throw new HTTPException(400, { message: "Can't rename a registered user" }); const { name } = await c.req.json(); if (!name?.trim()) throw new HTTPException(400, { message: "Name is required" }); @@ -168,14 +178,19 @@ participants.post("/:id/resend", async (c) => { Trip.findById(tripId).lean(), ]); if (!p || !trip) throw new HTTPException(404, { message: "Participant not found" }); - if (!(await isTripEditor(tripId, session.uid))) throw new HTTPException(403, { message: "Forbidden" }); + if (!(await isTripEditor(tripId, session.userId))) 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 User.findOne({ _id: session.userId }).select("name").lean(); + + const invite = newInviteToken(); + await Participant.updateOne({ _id: id }, { $set: invite }); + await sendInviteEmail({ tripName: trip.name, - fromName: session.name || "", + fromName: inviter?.name || "", email: p.email, - url: `${process.env.FRONTEND_URL}/accept/${p._id}`, + url: `${process.env.FRONTEND_URL}/accept/${invite.inviteToken}`, }); return c.json({}); }); @@ -189,7 +204,7 @@ participants.patch("/:id/mode", async (c) => { await connect(); const p = await Participant.findOne({ _id: id, tripId }).lean(); if (!p) throw new HTTPException(404, { message: "Participant not found" }); - if (!p.uid || p.uid !== session.uid) throw new HTTPException(403, { message: "Forbidden" }); + if (!p.userId || p.userId !== session.userId) throw new HTTPException(403, { message: "Forbidden" }); const { listMode } = await c.req.json<{ listMode: ParticipantListMode }>(); if (listMode !== "world" && listMode !== "custom") throw new HTTPException(400, { message: "Invalid list mode" }); @@ -215,13 +230,13 @@ participants.put("/:id/list", async (c) => { ]); if (!p || !trip) throw new HTTPException(404, { message: "Participant not found" }); - const isSelf = !!p.uid && p.uid === session.uid; - const isNameOnly = !p.uid && p.status === "active"; - const isPendingInvite = !p.uid && p.status === "pending"; + const isSelf = !!p.userId && p.userId === session.userId; + const isNameOnly = !p.userId && p.status === "active"; + const isPendingInvite = !p.userId && p.status === "pending"; const allowed = isSelf || - (isNameOnly && (await isTripEditor(tripId, session.uid))) || - (isPendingInvite && trip.ownerId === session.uid); + (isNameOnly && (await isTripEditor(tripId, session.userId))) || + (isPendingInvite && trip.ownerId === session.userId); if (!allowed) throw new HTTPException(403, { message: "Forbidden" }); const { sciNames } = await c.req.json(); @@ -248,18 +263,18 @@ participants.delete("/:id/list", async (c) => { ]); if (!p || !trip) throw new HTTPException(404, { message: "Participant not found" }); - const isSelf = !!p.uid && p.uid === session.uid; - const isNameOnly = !p.uid && p.status === "active"; - const isPendingInvite = !p.uid && p.status === "pending"; + const isSelf = !!p.userId && p.userId === session.userId; + const isNameOnly = !p.userId && p.status === "active"; + const isPendingInvite = !p.userId && p.status === "pending"; const allowed = isSelf || - (isNameOnly && (await isTripEditor(tripId, session.uid))) || - (isPendingInvite && trip.ownerId === session.uid); + (isNameOnly && (await isTripEditor(tripId, session.userId))) || + (isPendingInvite && trip.ownerId === session.userId); if (!allowed) throw new HTTPException(403, { message: "Forbidden" }); await Participant.updateOne( { _id: id }, - { $set: { lifelist: [], lifelistUpdatedAt: null, ...(p.uid ? { listMode: "world" } : {}) } } + { $set: { lifelist: [], lifelistUpdatedAt: null, ...(p.userId ? { listMode: "world" } : {}) } } ); return c.json({}); }); @@ -280,12 +295,12 @@ participants.post("/:id/seen", async (c) => { ]); if (!p || !trip) throw new HTTPException(404, { message: "Participant not found" }); - const isSelf = !!p.uid && p.uid === session.uid; - const isOwnerManagingNamed = !p.uid && trip.ownerId === session.uid; + const isSelf = !!p.userId && p.userId === session.userId; + const isOwnerManagingNamed = !p.userId && trip.ownerId === session.userId; if (!isSelf && !isOwnerManagingNamed) throw new HTTPException(403, { message: "Forbidden" }); - if (p.uid && p.listMode === "world") { - await Profile.updateOne({ uid: p.uid }, { $addToSet: { lifelist: code }, $pull: { exceptions: code } }); + if (p.userId && p.listMode === "world") { + await User.updateOne({ _id: p.userId }, { $addToSet: { lifelist: code }, $pull: { exceptions: code } }); } else { await Participant.updateOne({ _id: id }, { $addToSet: { lifelist: code }, $set: { lifelistUpdatedAt: new Date() } }); } @@ -303,8 +318,8 @@ participants.delete("/:id", async (c) => { if (!p) throw new HTTPException(404, { message: "Participant not found" }); if (p.isOwner) throw new HTTPException(400, { message: "Cannot remove the trip owner" }); - const isSelf = !!p.uid && p.uid === session.uid; - const isEditor = await isTripEditor(tripId, session.uid); + const isSelf = !!p.userId && p.userId === session.userId; + const isEditor = await isTripEditor(tripId, session.userId); if (!isSelf && !isEditor) throw new HTTPException(403, { message: "Forbidden" }); await Participant.deleteOne({ _id: id }); diff --git a/backend/routes/trips/[tripId]/targets.ts b/backend/routes/trips/[tripId]/targets.ts index cf32bafb..0d8ace9f 100644 --- a/backend/routes/trips/[tripId]/targets.ts +++ b/backend/routes/trips/[tripId]/targets.ts @@ -18,7 +18,7 @@ targets.patch("/add-star", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -39,7 +39,7 @@ targets.patch("/remove-star", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); @@ -60,7 +60,7 @@ targets.patch("/set-notes", async (c) => { await connect(); const [trip, isEditor] = await Promise.all([ Trip.findById(tripId).lean(), - isTripEditor(tripId, session.uid), + isTripEditor(tripId, session.userId), ]); if (!trip) throw new HTTPException(404, { message: "Trip not found" }); if (!isEditor) throw new HTTPException(403, { message: "Forbidden" }); diff --git a/backend/routes/trips/index.ts b/backend/routes/trips/index.ts index ea9d151c..3315dbf9 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, IntegrationToken, User } 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"; @@ -69,21 +69,21 @@ trips.get("/openbirding/:codeOrToken", shareCodeLimiter, async (c) => { throw new HTTPException(404, { message: "Trip not found" }); } - const shareToken = await TripShareToken.create({ tripId: trip._id, type: "openbirding" }); + const integrationToken = await IntegrationToken.create({ tripId: trip._id, type: "openbirding" }); - return c.json({ ...serializeTripForImport(trip), updateToken: shareToken._id }); + return c.json({ ...serializeTripForImport(trip), updateToken: integrationToken._id }); } - const shareToken = await TripShareToken.findOneAndUpdate( + const integrationToken = await IntegrationToken.findOneAndUpdate( { _id: codeOrToken, type: "openbirding" }, { $set: { lastUsedAt: new Date() } } ).lean(); - if (!shareToken) { + if (!integrationToken) { throw new HTTPException(404, { message: "Invalid token" }); } - const trip = await Trip.findById(shareToken.tripId).lean(); + const trip = await Trip.findById(integrationToken.tripId).lean(); if (!trip) { throw new HTTPException(404, { message: "Trip not found" }); } @@ -97,7 +97,7 @@ trips.get("/", async (c) => { const session = await authenticate(c); await connect(); - const tripIds = await Participant.find({ uid: session.uid, status: "active" }).distinct("tripId"); + const tripIds = await Participant.find({ userId: session.userId, status: "active" }).distinct("tripId"); const trips = await Trip.find({ _id: { $in: tripIds } }) .sort({ createdAt: -1 }) .lean(); @@ -118,10 +118,13 @@ trips.post("/", async (c) => { const imgUrl = await uploadMapboxImageToStorage(mapboxImgUrl); await connect(); + const user = await User.findOne({ _id: session.userId }).select("name").lean(); + const ownerName = user?.name || ""; + const trip = await Trip.create({ ...data, - ownerId: session.uid, - ownerName: session.name, + ownerId: session.userId, + ownerName, bounds, imgUrl, itinerary: [], @@ -131,8 +134,8 @@ trips.post("/", async (c) => { await Participant.create({ tripId: trip._id, - uid: session.uid, - name: session.name, + userId: session.userId, + name: ownerName, status: "active", listMode: "world", isOwner: true, diff --git a/backend/scripts/backfill-from-firebase.ts b/backend/scripts/backfill-from-firebase.ts new file mode 100644 index 00000000..79ef0fe1 --- /dev/null +++ b/backend/scripts/backfill-from-firebase.ts @@ -0,0 +1,213 @@ +// One-off pre-migration fix, sourcing identity from Firebase Auth by uid. Two parts: +// 1. Early users only got a `profiles` doc once they uploaded a life list — so users who +// authenticated and created trips without a list have trips but no profile. Create them. +// 2. Some existing profiles predate the email/name fields and are missing them. Fill them in. +// +// Run this BEFORE migrate-firebase-uid-to-userid.ts, against the pre-OTP schema. +// Defaults to a DRY RUN. Pass --execute to write. +// npx tsx backend/scripts/backfill-from-firebase.ts # dry run +// npx tsx backend/scripts/backfill-from-firebase.ts --execute # apply +import mongoose from "mongoose"; +import firebase from "firebase-admin"; +import { getAuth } from "firebase-admin/auth"; +import { customAlphabet } from "nanoid"; +import dotenv from "dotenv"; +import path from "path"; +import { fileURLToPath } from "url"; + +dotenv.config({ path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env") }); + +const MONGO_URI = process.env.MONGO_URI; +if (!MONGO_URI) { + console.error("Missing MONGO_URI environment variable"); + process.exit(1); +} +if (!process.env.FIREBASE_PRIVATE_KEY || !process.env.FIREBASE_CLIENT_EMAIL) { + console.error("Missing FIREBASE_PRIVATE_KEY / FIREBASE_CLIENT_EMAIL — needed to read from Firebase Auth"); + process.exit(1); +} + +const EXECUTE = process.argv.includes("--execute"); +const did = EXECUTE ? "" : "would "; +const nanoId = (length = 16) => + customAlphabet("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", length)(); + +firebase.initializeApp({ + credential: firebase.credential.cert({ + projectId: "bird-planner", + privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"), + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + }), +}); + +const isBlank = (v: any) => v === undefined || v === null || (typeof v === "string" && v.trim() === ""); + +async function run() { + await mongoose.connect(MONGO_URI!); + const db = mongoose.connection.db!; + console.log(`Connected — ${EXECUTE ? "EXECUTE" : "DRY RUN"}\n`); + + const profiles = db.collection("profiles"); + const trips = db.collection("trips"); + const participants = db.collection("participants"); + const invites = db.collection("invites"); + const now = new Date(); + + const profileDocs = (await profiles.find({}, { projection: { _id: 1, uid: 1, email: 1, name: 1 } }).toArray()) as any[]; + const knownUid = new Set(profileDocs.map((p) => p.uid).filter(Boolean)); + const emailToProfile = new Map(); + for (const p of profileDocs) if (p.email && p.uid) emailToProfile.set(String(p.email).toLowerCase(), { _id: p._id, uid: p.uid }); + + // --- Find orphan uids (referenced by a foreign key but with no profile) + a fallback name/date. + const orphans = new Map(); + const addOrphan = (uid: any, name?: any, date?: any) => { + if (!uid || knownUid.has(uid)) return; + const e = orphans.get(uid) || {}; + if (!e.name && name) e.name = name; + const d = date ? new Date(date) : undefined; + if (d && (!e.lastActiveAt || d > e.lastActiveAt)) e.lastActiveAt = d; + orphans.set(uid, e); + }; + for (const t of (await trips.find({}, { projection: { ownerId: 1, ownerName: 1, updatedAt: 1 } }).toArray()) as any[]) addOrphan(t.ownerId, t.ownerName, t.updatedAt); + for (const p of (await participants.find({ uid: { $exists: true } }, { projection: { uid: 1, name: 1, updatedAt: 1 } }).toArray()) as any[]) addOrphan(p.uid, p.name, p.updatedAt); + for (const i of (await invites.find({ uid: { $exists: true } }, { projection: { uid: 1, name: 1, updatedAt: 1 } }).toArray()) as any[]) addOrphan(i.uid, i.name, i.updatedAt); + + // --- Existing profiles missing name and/or email. + const incomplete = profileDocs.filter((p) => p.uid && (isBlank(p.email) || isBlank(p.name))); + + const orphanUids = [...orphans.keys()]; + console.log(`Orphan uids without a profile: ${orphanUids.length}`); + console.log(`Existing profiles missing name/email: ${incomplete.length}\n`); + + // --- One batched Firebase lookup for everything we need. + const lookupUids = [...new Set([...orphanUids, ...incomplete.map((p) => p.uid)])]; + const fbByUid = new Map(); + for (let i = 0; i < lookupUids.length; i += 100) { + const res = await getAuth().getUsers(lookupUids.slice(i, i + 100).map((uid) => ({ uid }))); + res.users.forEach((u) => fbByUid.set(u.uid, u)); + } + console.log(`Firebase Auth: ${fbByUid.size}/${lookupUids.length} found\n`); + + // === Part 1: create profiles for orphans (or merge if their email already has a profile). === + const toInsert: any[] = []; + const merges: string[] = []; + const orphanNoEmail: string[] = []; + + const mergeRepoint = async (fromUid: string, toUid: string) => { + if (!EXECUTE) return; + await trips.updateMany({ ownerId: fromUid }, { $set: { ownerId: toUid } }); + await participants.updateMany({ uid: fromUid }, { $set: { uid: toUid } }); + await invites.updateMany({ uid: fromUid }, { $set: { uid: toUid } }); + await invites.updateMany({ ownerId: fromUid }, { $set: { ownerId: toUid } }); + }; + + for (const uid of orphanUids) { + const fb = fbByUid.get(uid); + const fallback = orphans.get(uid)!; + const email = fb?.email?.toLowerCase(); + const name = fb?.displayName || fallback.name || undefined; + if (!email) { + orphanNoEmail.push(`${uid} (${name ?? "?"})`); + continue; + } + const existing = emailToProfile.get(email); + if (existing) { + merges.push(`${uid} (${name ?? "?"}) → existing profile ${existing._id} <${email}>`); + await mergeRepoint(uid, existing.uid); + continue; + } + const created = fb?.metadata?.creationTime ? new Date(fb.metadata.creationTime) : undefined; + const lastSignIn = fb?.metadata?.lastSignInTime ? new Date(fb.metadata.lastSignInTime) : undefined; + const doc = { + _id: nanoId(), + uid, + name: name ?? null, + email, + lifelist: [], + exceptions: [], + lastActiveAt: lastSignIn ?? fallback.lastActiveAt ?? now, + lastAuthenticatedAt: null, + isAdmin: false, + createdAt: created ?? fallback.lastActiveAt ?? now, + updatedAt: now, + }; + toInsert.push(doc); + emailToProfile.set(email, { _id: doc._id, uid }); + } + + // === Part 2: fill missing name/email on existing profiles. === + const fieldOps: any[] = []; + const fieldReport: string[] = []; + const fieldCollisions: string[] = []; + const fieldNoData: string[] = []; + + for (const p of incomplete) { + const fb = fbByUid.get(p.uid); + if (!fb) { + fieldNoData.push(`${p._id} uid=${p.uid} (not in Firebase)`); + continue; + } + const set: any = {}; + if (isBlank(p.name) && fb.displayName) set.name = fb.displayName; + if (isBlank(p.email) && fb.email) { + const email = fb.email.toLowerCase(); + const owner = emailToProfile.get(email); + if (owner && owner._id !== p._id) { + fieldCollisions.push(`${p._id} uid=${p.uid}: Firebase email <${email}> already on profile ${owner._id}`); + } else { + set.email = email; + emailToProfile.set(email, { _id: p._id, uid: p.uid }); + } + } + if (Object.keys(set).length) { + fieldOps.push({ updateOne: { filter: { _id: p._id }, update: { $set: { ...set, updatedAt: now } } } }); + fieldReport.push(`${p._id} uid=${p.uid} += ${JSON.stringify(set)}`); + } else if (!fieldCollisions.some((c) => c.startsWith(p._id))) { + fieldNoData.push(`${p._id} uid=${p.uid} (Firebase has no matching field)`); + } + } + + // --- Report + write. + console.log(`${did}create ${toInsert.length} profile(s):`); + toInsert.forEach((d) => console.log(` _id=${d._id} uid=${d.uid} <${d.email}> "${d.name ?? "?"}"`)); + if (merges.length) { + console.log(`\n${did}merge ${merges.length} orphan(s) into an existing profile (same email):`); + merges.forEach((m) => console.log(` ${m}`)); + } + if (orphanNoEmail.length) { + console.log(`\nāš ļø ${orphanNoEmail.length} orphan(s) have NO email in Firebase — left as-is:`); + orphanNoEmail.forEach((n) => console.log(` ${n}`)); + } + + console.log(`\n${did}backfill fields on ${fieldOps.length} existing profile(s):`); + fieldReport.forEach((r) => console.log(` ${r}`)); + if (fieldCollisions.length) { + console.log(`\nāš ļø ${fieldCollisions.length} profile(s) skipped — Firebase email collides with another profile:`); + fieldCollisions.forEach((c) => console.log(` ${c}`)); + } + if (fieldNoData.length) { + console.log(`\nāš ļø ${fieldNoData.length} incomplete profile(s) had nothing to fill from Firebase:`); + fieldNoData.forEach((n) => console.log(` ${n}`)); + } + + // Safety assertion: after this runs, email is the OTP login key — no user should be left without one. + const willHaveEmail = (p: any) => !isBlank(p.email) || fieldOps.some((o) => o.updateOne.filter._id === p._id && o.updateOne.update.$set.email); + const stillNoEmail = profileDocs.filter((p) => !willHaveEmail(p) && !merges.some((m) => m.includes(p.uid))); + console.log(`\nUsers that would STILL lack an email (can't OTP-login): ${stillNoEmail.length}`); + stillNoEmail.forEach((p) => console.log(` ${p._id} uid=${p.uid} name=${p.name ?? "?"}`)); + + if (EXECUTE) { + if (toInsert.length) await profiles.insertMany(toInsert); + if (fieldOps.length) await profiles.bulkWrite(fieldOps); + } + + console.log(`\nDone (${EXECUTE ? "EXECUTE" : "DRY RUN"}).`); + if (!EXECUTE) console.log("No changes written. Re-run with --execute, then run migrate-firebase-uid-to-userid.ts."); + + await mongoose.disconnect(); +} + +run().catch((err) => { + console.error("\nBackfill failed:", err); + process.exit(1); +}); diff --git a/backend/scripts/migrate-firebase-uid-to-userid.ts b/backend/scripts/migrate-firebase-uid-to-userid.ts new file mode 100644 index 00000000..89420d2a --- /dev/null +++ b/backend/scripts/migrate-firebase-uid-to-userid.ts @@ -0,0 +1,186 @@ +// One-time migration: collapse the redundant Firebase `uid` onto the app's own `_id`. +// +// Pre-OTP prod schema (the only schema this targets — the OTP branch is not deployed): +// profiles: _id (nanoId/ObjectId-string), uid (28-char Firebase uid) +// trips: ownerId = Firebase uid +// invites: ownerId = Firebase uid, uid = Firebase uid (when accepted) +// participants: uid = Firebase uid (when registered) +// +// After: +// users (renamed from profiles): _id stays canonical, `uid` field + its index dropped +// trips.ownerId / invites.ownerId / invites.userId / participants.userId all hold a User._id +// +// Defaults to a DRY RUN (reports only). Pass --execute to write. +// npx tsx backend/scripts/migrate-firebase-uid-to-userid.ts # dry run +// npx tsx backend/scripts/migrate-firebase-uid-to-userid.ts --execute # apply +// add --force to proceed even if orphan foreign keys are found +import mongoose from "mongoose"; +import dotenv from "dotenv"; +import path from "path"; +import { fileURLToPath } from "url"; + +dotenv.config({ path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env") }); + +const MONGO_URI = process.env.MONGO_URI; +if (!MONGO_URI) { + console.error("Missing MONGO_URI environment variable"); + process.exit(1); +} + +const EXECUTE = process.argv.includes("--execute"); +const FORCE = process.argv.includes("--force"); +const mode = EXECUTE ? "EXECUTE" : "DRY RUN"; +const did = EXECUTE ? "" : "would "; + +async function migrate() { + await mongoose.connect(MONGO_URI!); + const db = mongoose.connection.db!; + console.log(`Connected to MongoDB — ${mode}\n`); + + const profiles = db.collection("profiles"); + const usersTarget = db.collection("users"); + const trips = db.collection("trips"); + const invites = db.collection("invites"); + const participants = db.collection("participants"); + + // An empty `users` collection is auto-created the moment the User model registers against + // this DB, and it silently blocks the final rename ("target namespace exists"). Drop it if + // empty; only a populated `users` means the migration already ran, which is a real abort. + const usersExists = (await db.listCollections({ name: "users" }).toArray()).length > 0; + if (usersExists && (await usersTarget.countDocuments({})) > 0) { + throw new Error("A non-empty `users` collection already exists — has this migration already run? Aborting."); + } + if (usersExists) { + console.log(`${did}drop the empty auto-created \`users\` collection before renaming`); + } + + // 1. Build firebaseUid -> _id map and sanity-check the canonical ids. + const profileDocs = (await profiles.find({}, { projection: { _id: 1, uid: 1 } }).toArray()) as unknown as { + _id: string; + uid?: string; + }[]; + const uidToId = new Map(); + let missingUid = 0; + let badId = 0; + for (const p of profileDocs) { + if (typeof p._id !== "string" || p._id.length < 16) badId++; + if (!p.uid) { + missingUid++; + continue; + } + uidToId.set(p.uid, p._id); + } + console.log(`profiles: ${profileDocs.length} total, ${uidToId.size} with a uid`); + if (missingUid) console.warn(` āš ļø ${missingUid} profile(s) have no uid (will keep _id, nothing to map)`); + if (badId) console.warn(` āš ļø ${badId} profile(s) have a non-string/short _id — review before keeping _id as canonical`); + + // 2. Pre-flight: find foreign keys that don't resolve to a profile. + const resolves = (v?: string | null) => !v || uidToId.has(v); + const orphans: string[] = []; + + const tripOwners = (await trips.distinct("ownerId")) as string[]; + const orphanTripOwners = tripOwners.filter((v) => !resolves(v)); + if (orphanTripOwners.length) orphans.push(`trips.ownerId: ${orphanTripOwners.length} unmatched (${orphanTripOwners.slice(0, 5).join(", ")}${orphanTripOwners.length > 5 ? ", …" : ""})`); + + const partUids = ((await participants.distinct("uid")) as string[]).filter(Boolean); + const orphanPartUids = partUids.filter((v) => !resolves(v)); + if (orphanPartUids.length) orphans.push(`participants.uid: ${orphanPartUids.length} unmatched`); + + const inviteOwners = (await invites.distinct("ownerId")) as string[]; + const orphanInviteOwners = inviteOwners.filter((v) => !resolves(v)); + if (orphanInviteOwners.length) orphans.push(`invites.ownerId: ${orphanInviteOwners.length} unmatched`); + + const inviteUids = ((await invites.distinct("uid")) as string[]).filter(Boolean); + const orphanInviteUids = inviteUids.filter((v) => !resolves(v)); + if (orphanInviteUids.length) orphans.push(`invites.uid: ${orphanInviteUids.length} unmatched`); + + if (orphans.length) { + console.warn(`\nāš ļø Orphan foreign keys (no matching profile) — these will be LEFT UNCHANGED:`); + orphans.forEach((o) => console.warn(` - ${o}`)); + if (!FORCE && EXECUTE) { + throw new Error("Orphan foreign keys found. Re-run with --force to proceed (orphans are left as-is)."); + } + } else { + console.log("Pre-flight: every foreign key resolves to a profile. āœ…"); + } + + // Repoint a collection's field from Firebase uid -> _id. `rename` also $unsets the old field name. + const repoint = async ( + coll: typeof trips, + fromField: string, + toField: string, + label: string + ) => { + const docs = (await coll.find({ [fromField]: { $exists: true, $ne: null } }, { projection: { [fromField]: 1 } }).toArray()) as Record[]; + const ops = []; + for (const d of docs) { + const oldVal = d[fromField]; + const newVal = uidToId.get(oldVal); + if (!newVal) continue; // orphan — leave unchanged + const update: Record = { $set: { [toField]: newVal } }; + if (fromField !== toField) update.$unset = { [fromField]: "" }; + ops.push({ updateOne: { filter: { _id: d._id }, update } }); + } + console.log(`${label}: ${docs.length} doc(s) with ${fromField}, ${ops.length} to repoint${fromField !== toField ? ` (rename ${fromField}→${toField})` : ""}`); + if (EXECUTE && ops.length) await coll.bulkWrite(ops); + }; + + console.log(""); + await repoint(trips, "ownerId", "ownerId", "trips.ownerId"); + await repoint(invites, "ownerId", "ownerId", "invites.ownerId"); + await repoint(invites, "uid", "userId", "invites.uid"); + await repoint(participants, "uid", "userId", "participants.userId"); + + // Orphan foreign keys (uid with no matching profile) are skipped above and keep a stale `uid`. + // Drop it so no `uid` survives anywhere — those rows simply become name-only participants. + const keys = [...uidToId.keys()]; + const stalePart = await participants.countDocuments({ uid: { $exists: true, $nin: keys } }); + const staleInvite = await invites.countDocuments({ uid: { $exists: true, $nin: keys } }); + if (EXECUTE) { + await participants.updateMany({ uid: { $exists: true, $nin: keys } }, { $unset: { uid: "" } }); + await invites.updateMany({ uid: { $exists: true, $nin: keys } }, { $unset: { uid: "" } }); + } + console.log(`${did}drop stale orphan uid on ${stalePart} participant(s), ${staleInvite} invite(s)`); + + // 3. Drop the `uid` field + its unique index from profiles, then rename profiles -> users. + const dropIndex = async (coll: typeof profiles, name: string) => { + try { + if (EXECUTE) await coll.dropIndex(name); + console.log(` ${did}drop index ${name} on ${coll.collectionName}`); + } catch { + console.log(` index ${name} on ${coll.collectionName} not present (skipped)`); + } + }; + + console.log("\nUsers collection:"); + await dropIndex(profiles, "uid_1"); + if (EXECUTE) await profiles.updateMany({}, { $unset: { uid: "" } }); + console.log(` ${did}$unset uid on ${profileDocs.length} profile(s)`); + if (EXECUTE) { + if (usersExists) await usersTarget.drop(); + await profiles.rename("users"); + } + console.log(` ${did}rename collection profiles → users`); + + // 4. Replace stale uid-based indexes on participants/invites with userId-based ones. + console.log("\nIndexes:"); + await dropIndex(participants, "uid_1"); + await dropIndex(participants, "tripId_1_uid_1"); + await dropIndex(invites, "tripId_1_uid_1"); + if (EXECUTE) { + await participants.createIndex({ userId: 1 }); + await participants.createIndex({ tripId: 1, userId: 1 }, { unique: true, partialFilterExpression: { userId: { $exists: true } } }); + await invites.createIndex({ tripId: 1, userId: 1 }); + } + console.log(` ${did}create userId-based indexes on participants/invites`); + + console.log(`\nDone (${mode}).`); + if (!EXECUTE) console.log("No changes were written. Re-run with --execute to apply."); + + await mongoose.disconnect(); +} + +migrate().catch((err) => { + console.error("\nMigration failed:", err); + process.exit(1); +}); diff --git a/backend/scripts/rename-tripsharetoken-to-integrationtoken.ts b/backend/scripts/rename-tripsharetoken-to-integrationtoken.ts new file mode 100644 index 00000000..e6c2847a --- /dev/null +++ b/backend/scripts/rename-tripsharetoken-to-integrationtoken.ts @@ -0,0 +1,67 @@ +// One-time migration: rename the `tripsharetokens` collection to `integrationtokens` +// (model TripShareToken → IntegrationToken). Pure rename — fields and indexes are unchanged +// and travel with the collection. +// +// Defaults to a DRY RUN (reports only). Pass --execute to write. +// npx tsx backend/scripts/rename-tripsharetoken-to-integrationtoken.ts # dry run +// npx tsx backend/scripts/rename-tripsharetoken-to-integrationtoken.ts --execute # apply +import mongoose from "mongoose"; +import dotenv from "dotenv"; +import path from "path"; +import { fileURLToPath } from "url"; + +dotenv.config({ path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env") }); + +const MONGO_URI = process.env.MONGO_URI; +if (!MONGO_URI) { + console.error("Missing MONGO_URI environment variable"); + process.exit(1); +} + +const EXECUTE = process.argv.includes("--execute"); +const mode = EXECUTE ? "EXECUTE" : "DRY RUN"; +const did = EXECUTE ? "" : "would "; + +async function migrate() { + await mongoose.connect(MONGO_URI!); + const db = mongoose.connection.db!; + console.log(`Connected to MongoDB — ${mode}\n`); + + const source = db.collection("tripsharetokens"); + const target = db.collection("integrationtokens"); + + const sourceExists = (await db.listCollections({ name: "tripsharetokens" }).toArray()).length > 0; + if (!sourceExists) { + throw new Error("No `tripsharetokens` collection found — nothing to rename. Has this already run?"); + } + + // An empty `integrationtokens` collection is auto-created the moment the IntegrationToken model + // registers against this DB, and it silently blocks the rename ("target namespace exists"). Drop + // it if empty; a populated `integrationtokens` means the migration already ran, which is a real abort. + const targetExists = (await db.listCollections({ name: "integrationtokens" }).toArray()).length > 0; + if (targetExists && (await target.countDocuments({})) > 0) { + throw new Error("A non-empty `integrationtokens` collection already exists — has this migration already run? Aborting."); + } + if (targetExists) { + console.log(`${did}drop the empty auto-created \`integrationtokens\` collection before renaming`); + } + + const count = await source.countDocuments({}); + console.log(`tripsharetokens: ${count} doc(s) to carry over`); + + if (EXECUTE) { + if (targetExists) await target.drop(); + await source.rename("integrationtokens"); + } + console.log(`${did}rename collection tripsharetokens → integrationtokens`); + + console.log(`\nDone (${mode}).`); + if (!EXECUTE) console.log("No changes were written. Re-run with --execute to apply."); + + await mongoose.disconnect(); +} + +migrate().catch((err) => { + console.error("\nMigration failed:", err); + process.exit(1); +}); diff --git a/frontend/RootLayout.tsx b/frontend/RootLayout.tsx index b338f66f..83577429 100644 --- a/frontend/RootLayout.tsx +++ b/frontend/RootLayout.tsx @@ -1,24 +1,16 @@ import { Outlet } from "react-router-dom"; import { Toaster } from "react-hot-toast"; -import { UserProvider } from "providers/user"; -import { ModalProvider } from "providers/modals"; -import { ProfileProvider } from "providers/profile"; -import { TripProvider } from "providers/trip"; -import { SpeciesImagesProvider } from "providers/species-images"; +import { ModalRoot } from "components/Modal"; +import { useClearSelectedSpeciesOnNavigate } from "hooks/useTrip"; export default function RootLayout() { + useClearSelectedSpeciesOnNavigate(); + return ( - - - - - - - - - - - - + <> + + + + ); } diff --git a/frontend/components/AccountDropdown.tsx b/frontend/components/AccountDropdown.tsx index 25017c22..a2a4fadf 100644 --- a/frontend/components/AccountDropdown.tsx +++ b/frontend/components/AccountDropdown.tsx @@ -7,11 +7,10 @@ import { } from "components/ui/dropdown-menu"; import { User, Feather, LogOut } from "lucide-react"; import Avatar from "components/Avatar"; -import { avatarFromProfile } from "lib/avatar"; -import { useUser } from "providers/user"; +import { avatarFromUser } from "lib/avatar"; +import { useUser } from "hooks/useUser"; import { Link, useLocation } from "react-router-dom"; -import useFirebaseLogout from "hooks/useFirebaseLogout"; -import { useProfile } from "providers/profile"; +import useLogout from "hooks/useLogout"; import { withReturnTo } from "lib/helpers"; type Props = { @@ -22,11 +21,9 @@ type Props = { const itemClass = "gap-2 px-3 py-2.5 text-sm font-medium text-gray-700"; const AccountDropdown = ({ className, dropUp }: Props) => { - const { user } = useUser(); - const profile = useProfile(); - const { lifelist } = profile; + const { user, lifelist } = useUser(); const lifelistCount = lifelist?.length || 0; - const { logout } = useFirebaseLogout(); + const { logout } = useLogout(); const location = useLocation(); const asPath = `${location.pathname}${location.search}`; @@ -39,14 +36,14 @@ const AccountDropdown = ({ className, dropUp }: Props) => { className || "rounded-full transition-all duration-200 hover:ring-2 hover:ring-gray-200 hover:ring-offset-2" } > - +
- +
-

{profile.name}

- {profile.email &&

{profile.email}

} +

{user.name}

+ {user.email &&

{user.email}

}
diff --git a/frontend/components/AuthForm.tsx b/frontend/components/AuthForm.tsx new file mode 100644 index 00000000..42f8e5d2 --- /dev/null +++ b/frontend/components/AuthForm.tsx @@ -0,0 +1,185 @@ +import React from "react"; +import { Link, 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 useReportNoCode from "hooks/useReportNoCode"; +import useNavContext from "hooks/useNavContext"; +import { getPostAuthDest, withReturnTo } from "lib/helpers"; + +type Props = { + heading?: string; + message?: string; + email?: string; + lockEmail?: boolean; +}; + +const RESEND_COOLDOWN = 30; + +export default function AuthForm({ heading, message, email: initialEmail, lockEmail }: 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 [showHelp, setShowHelp] = React.useState(false); + + const requestCode = useRequestCode(); + const verifyCode = useVerifyCode(); + const reportNoCode = useReportNoCode(); + + 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 backToEmail = () => { + setStep("email"); + setCode(""); + setCooldown(0); + setShowHelp(false); + setError(null); + }; + + const handleEmailChange = (e: React.ChangeEvent) => { + setEmail(e.target.value); + if (step === "code") backToEmail(); + }; + + 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() }); + const dest = 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" && ( +
+ {showHelp ? ( +
+

+ We sent a code to {email}.{" "} + +

+
    +
  • It can take 1–2 minutes to arrive.
  • +
  • Check your spam or junk folder.
  • +
  • + {cooldown > 0 ? ( + Resend in {cooldown}s + ) : ( + + )} +
  • +
+

+ Still stuck?{" "} + + Contact us + +

+
+ ) : ( + + )} +
+ )} +
+ + ); +} diff --git a/frontend/components/DirectionsButton.tsx b/frontend/components/DirectionsButton.tsx index 744a1290..fce9921f 100644 --- a/frontend/components/DirectionsButton.tsx +++ b/frontend/components/DirectionsButton.tsx @@ -1,7 +1,7 @@ import React from "react"; import Button from "components/Button"; import SlideOver from "components/SlideOver"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import MarkerWithIcon from "components/MarkerWithIcon"; import Icon from "components/Icon"; import OrganicMapsIcon from "components/OrganicMapsIcon"; 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/FavButton.tsx b/frontend/components/FavButton.tsx index b5a6673a..e4010564 100644 --- a/frontend/components/FavButton.tsx +++ b/frontend/components/FavButton.tsx @@ -1,6 +1,6 @@ import React from "react"; import Icon from "components/Icon"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import useTripMutation from "hooks/useTripMutation"; import { HotspotFav } from "@birdplan/shared"; diff --git a/frontend/components/Footer.tsx b/frontend/components/Footer.tsx index 973e9c5b..cce617ca 100644 --- a/frontend/components/Footer.tsx +++ b/frontend/components/Footer.tsx @@ -3,12 +3,7 @@ import { Link } from "react-router-dom"; export default function Footer() { return (
- Developed by{" "} - - RawComposition - - • - Support + Contact • What's New • diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx index 58909130..b4fe033a 100644 --- a/frontend/components/Header.tsx +++ b/frontend/components/Header.tsx @@ -4,9 +4,9 @@ import Icon from "components/Icon"; import Button from "components/Button"; import useRealtimeStatus from "hooks/useRealtimeStatus"; import clsx from "clsx"; -import { useTrip } from "providers/trip"; -import { useModal } from "providers/modals"; -import { useUser } from "providers/user"; +import { useTrip } from "hooks/useTrip"; +import { useModal } from "stores/modals"; +import { useUser } from "hooks/useUser"; import AccountDropdown from "components/AccountDropdown"; import BreadcrumbArrow from "components/BreadcrumbArrow"; import Logo from "components/Logo"; @@ -45,14 +45,14 @@ export default function Header({ title, parent, border }: Props) {
)}

BirdPlan.app

{isSubPage && ( - + )} diff --git a/frontend/components/HomeHeader.tsx b/frontend/components/HomeHeader.tsx index 8b501e7d..2593b79c 100644 --- a/frontend/components/HomeHeader.tsx +++ b/frontend/components/HomeHeader.tsx @@ -1,11 +1,11 @@ import { Link } from "react-router-dom"; import clsx from "clsx"; import Button from "components/Button"; -import { useUser } from "providers/user"; +import { useUser } from "hooks/useUser"; import Logo from "components/Logo"; export default function HomeHeader() { const { user } = useUser(); - const isLoggedIn = !!user?.uid; + const isLoggedIn = !!user?._id; return (
diff --git a/frontend/components/HotspotFavs.tsx b/frontend/components/HotspotFavs.tsx index fa2909e4..d0fd5135 100644 --- a/frontend/components/HotspotFavs.tsx +++ b/frontend/components/HotspotFavs.tsx @@ -1,6 +1,6 @@ import React from "react"; import FavButton from "components/FavButton"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; type Props = { hotspotId: string; diff --git a/frontend/components/HotspotTargetRow.tsx b/frontend/components/HotspotTargetRow.tsx index 0cabb6ae..6eff78c3 100644 --- a/frontend/components/HotspotTargetRow.tsx +++ b/frontend/components/HotspotTargetRow.tsx @@ -1,7 +1,7 @@ import React from "react"; import FavButton from "components/FavButton"; import MutualBadge from "components/MutualBadge"; -import { useSpeciesImages } from "providers/species-images"; +import { useSpeciesImages } from "hooks/useSpeciesImages"; type Props = { code: string; @@ -19,7 +19,6 @@ export default function HotspotTargetRow({ code, name, frequency, - index, hotspotId, range, isSaved, diff --git a/frontend/components/HotspotTargets.tsx b/frontend/components/HotspotTargets.tsx index f0d94c1d..57181426 100644 --- a/frontend/components/HotspotTargets.tsx +++ b/frontend/components/HotspotTargets.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import Icon from "components/Icon"; import Button from "components/Button"; import HotspotTargetRow from "components/HotspotTargetRow"; @@ -18,7 +18,7 @@ type Props = { onAddToTrip: () => void; }; -export default function HotspotTargets({ hotspotId, onSpeciesClick, onAddToTrip }: Props) { +export default function HotspotTargets({ hotspotId, onSpeciesClick }: Props) { const [view, setView] = React.useState("all"); const { trip, dateRangeLabel } = useTrip(); const { lifelist } = useTargetView(trip); diff --git a/frontend/components/InputNotes.tsx b/frontend/components/InputNotes.tsx index 643961d2..9ee42a51 100644 --- a/frontend/components/InputNotes.tsx +++ b/frontend/components/InputNotes.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import TextareaAutosize from "react-textarea-autosize"; type Props = { diff --git a/frontend/components/ItineraryDay.tsx b/frontend/components/ItineraryDay.tsx index 1b13e6df..644a5285 100644 --- a/frontend/components/ItineraryDay.tsx +++ b/frontend/components/ItineraryDay.tsx @@ -1,8 +1,8 @@ import React from "react"; import Button from "components/Button"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import dayjs from "dayjs"; -import { useModal } from "providers/modals"; +import { useModal } from "stores/modals"; import MarkerWithIcon from "components/MarkerWithIcon"; import TravelTime from "components/TravelTime"; import InputNotesSimple from "components/InputNotesSimple"; @@ -35,7 +35,7 @@ export default function ItineraryDay({ day, dayIndex, isEditing }: PropsT) { const removeDayMutation = useTripMutation({ url: `/trips/${trip?._id}/itinerary/${day.id}`, method: "DELETE", - updateCache: (old, input) => ({ + updateCache: (old) => ({ ...old, itinerary: old.itinerary?.filter((it) => it.id !== day.id) || [], }), diff --git a/frontend/components/LifelistModeChooser.tsx b/frontend/components/LifelistModeChooser.tsx index f206c0c6..0042bd09 100644 --- a/frontend/components/LifelistModeChooser.tsx +++ b/frontend/components/LifelistModeChooser.tsx @@ -3,8 +3,8 @@ import clsx from "clsx"; import { Link, useLocation } from "react-router-dom"; import { Trip } from "@birdplan/shared"; import LifelistField from "components/LifelistField"; -import { useProfile } from "providers/profile"; -import { useModal } from "providers/modals"; +import { useUser } from "hooks/useUser"; +import { useModal } from "stores/modals"; import useTripLifelist from "hooks/useTripLifelist"; import useLifelistMode from "hooks/useLifelistMode"; import { withReturnTo } from "lib/helpers"; @@ -16,7 +16,7 @@ type Props = { }; export default function LifelistModeChooser({ trip, canEdit, mode }: Props) { - const { lifelist: worldList } = useProfile(); + const { lifelist: worldList } = useUser(); const { myLifelist } = useTripLifelist(trip); const location = useLocation(); const asPath = `${location.pathname}${location.search}`; 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/Mapbox.tsx b/frontend/components/Mapbox.tsx index 9f09d2cd..6f655dca 100644 --- a/frontend/components/Mapbox.tsx +++ b/frontend/components/Mapbox.tsx @@ -6,8 +6,8 @@ import { MarkerIconT } from "lib/icons"; import { markerColors, getLatLngFromBounds } from "lib/helpers"; import MarkerWithIcon from "components/MarkerWithIcon"; import clsx from "clsx"; -import { useModal } from "providers/modals"; -import { useTrip } from "providers/trip"; +import { useModal } from "stores/modals"; +import { useTrip } from "hooks/useTrip"; type Props = { bounds: Trip["bounds"]; diff --git a/frontend/components/Modal.tsx b/frontend/components/Modal.tsx new file mode 100644 index 00000000..1bb00622 --- /dev/null +++ b/frontend/components/Modal.tsx @@ -0,0 +1,111 @@ +import React from "react"; + +// components +import ModalWrapper from "components/ModalWrapper"; +import { DialogTitle } from "components/ui/dialog"; +import clsx from "clsx"; + +// stores +import { ModalId, MODAL_POSITIONS, useModal, useModalStore } from "stores/modals"; + +// modals +import Hotspot from "modals/Hotspot"; +import PersonalLocation from "modals/PersonalLocation"; +import Marker from "modals/Marker"; +import AddItineraryLocation from "modals/AddItineraryLocation"; +import AddHotspot from "modals/AddHotspot"; +import AddPlace from "modals/AddPlace"; +import DeleteAccount from "modals/DeleteAccount"; +import OpenBirding from "modals/OpenBirding"; +import AddParticipant from "modals/AddParticipant"; +import InviteAsEditor from "modals/InviteAsEditor"; +import ManageLifelist from "modals/ManageLifelist"; +import GenerateMagicLink from "modals/GenerateMagicLink"; + +type ModalConfig = { + Component: React.ComponentType; + maxHeight?: number | string; +}; + +const modals: Record = { + hotspot: { Component: Hotspot }, + personalLocation: { Component: PersonalLocation }, + addMarker: { Component: Marker }, + addPlace: { Component: AddPlace }, + addHotspot: { Component: AddHotspot }, + viewMarker: { Component: Marker }, + addItineraryLocation: { Component: AddItineraryLocation }, + deleteAccount: { Component: DeleteAccount, maxHeight: "90vh" }, + openBirding: { Component: OpenBirding }, + addParticipant: { Component: AddParticipant }, + inviteAsEditor: { Component: InviteAsEditor }, + manageLifelist: { Component: ManageLifelist }, + generateMagicLink: { Component: GenerateMagicLink }, +}; + +const ModalRoot = () => { + const modalId = useModalStore((s) => s.modalId); + const modalProps = useModalStore((s) => s.modalProps); + const closing = useModalStore((s) => s.closing); + const close = useModalStore((s) => s.close); + + const modal = modalId ? modals[modalId] : null; + const Component = modal?.Component as React.ElementType; + + const handleDismiss = () => { + close(); + modalProps?.onDismiss?.(); + }; + + return ( + + {modal && } + + ); +}; + +const Footer = ({ children }: { children: React.ReactNode }) => { + const { position } = useModal(); + return ( +
+ {children} +
+ ); +}; + +const Header = ({ children }: { children: React.ReactNode }) => { + const { position } = useModal(); + return position === "center" ? ( + {children} + ) : ( + + {children} + + ); +}; + +const Body = ({ + children, + className, + noPadding, +}: { + children: React.ReactNode; + className?: string; + noPadding?: boolean; +}) => { + const { position } = useModal(); + const padding = position === "center" ? "px-6 sm:px-7 pt-4" : "px-4 sm:px-6 pt-4"; + return
{children}
; +}; + +export { ModalRoot, Footer, Header, Body }; diff --git a/frontend/components/ModalWrapper.tsx b/frontend/components/ModalWrapper.tsx index 5ae5d988..dd410350 100644 --- a/frontend/components/ModalWrapper.tsx +++ b/frontend/components/ModalWrapper.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Dialog, DialogContent } from "components/ui/dialog"; import { Sheet, SheetContent } from "components/ui/sheet"; import ErrorBoundary from "components/ErrorBoundary"; -import { ModalPosition } from "providers/modals"; +import { ModalPosition } from "stores/modals"; type Props = { open: boolean; diff --git a/frontend/components/Notice.tsx b/frontend/components/Notice.tsx index 407028bb..1e8b27e9 100644 --- a/frontend/components/Notice.tsx +++ b/frontend/components/Notice.tsx @@ -1,15 +1,15 @@ import { Link, useLocation } from "react-router-dom"; import CloseButton from "components/CloseButton"; -import { useProfile } from "providers/profile"; +import { useUser } from "hooks/useUser"; import useMutation from "hooks/useMutation"; import { useQueryClient } from "@tanstack/react-query"; -import { Profile } from "@birdplan/shared"; +import { User } from "@birdplan/shared"; import { withReturnTo } from "lib/helpers"; const noticeId = ""; export default function Notice() { - const { _id, dismissedNoticeId } = useProfile(); + const { user } = useUser(); const queryClient = useQueryClient(); const location = useLocation(); const asPath = `${location.pathname}${location.search}`; @@ -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,13 +29,13 @@ export default function Notice() { return { prevData }; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [`/profile`] }); + queryClient.invalidateQueries({ queryKey: ["/auth/me"] }); }, }); if (!noticeId) return null; - if (!_id) return null; - if (dismissedNoticeId === noticeId) return null; + if (!user?._id) return null; + if (user.dismissedNoticeId === noticeId) return null; return (
diff --git a/frontend/components/ObsList.tsx b/frontend/components/ObsList.tsx index a8cbeef1..8211a584 100644 --- a/frontend/components/ObsList.tsx +++ b/frontend/components/ObsList.tsx @@ -2,7 +2,7 @@ import React from "react"; import Icon from "components/Icon"; import Button from "components/Button"; import { dateTimeToRelative } from "lib/helpers"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import dayjs from "dayjs"; import useFetchHotspotObs from "hooks/useFetchHotspotObs"; import useFetchRecentChecklists from "hooks/useFetchRecentChecklists"; diff --git a/frontend/components/ParticipantRow.tsx b/frontend/components/ParticipantRow.tsx index aecb71f3..1157e1cc 100644 --- a/frontend/components/ParticipantRow.tsx +++ b/frontend/components/ParticipantRow.tsx @@ -3,8 +3,8 @@ import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { ParticipantView } from "@birdplan/shared"; -import { useTrip } from "providers/trip"; -import { useModal } from "providers/modals"; +import { useTrip } from "hooks/useTrip"; +import { useModal } from "stores/modals"; import useMutation from "hooks/useMutation"; import ParticipantOptionsDropdown, { ParticipantMenuItem } from "components/ParticipantOptionsDropdown"; import Badge from "components/Badge"; @@ -25,7 +25,7 @@ export default function ParticipantRow({ participant: p }: Props) { const isSelf = p.isMe; const isPending = p.status === "pending"; - const isNameOnly = !p.uid && !isPending; + const isNameOnly = !p.userId && !isPending; const canChangeList = isSelf; const invalidate = () => { 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/ReactSelectAsyncStyled.tsx b/frontend/components/ReactSelectAsyncStyled.tsx index 319738f4..c8a9f129 100644 --- a/frontend/components/ReactSelectAsyncStyled.tsx +++ b/frontend/components/ReactSelectAsyncStyled.tsx @@ -27,11 +27,11 @@ export default function ReactSelectAsyncStyled(props: SelectProps) { fontWeight: "normal", fontSize: "0.875rem", }), - input: (base, state) => ({ + input: (base) => ({ ...base, fontSize: "1rem", }), - container: (base, state) => ({ + container: (base) => ({ ...base, fontSize: "0.875rem", }), diff --git a/frontend/components/ReactSelectStyled.tsx b/frontend/components/ReactSelectStyled.tsx index 121e5f81..fbdaefdc 100644 --- a/frontend/components/ReactSelectStyled.tsx +++ b/frontend/components/ReactSelectStyled.tsx @@ -27,11 +27,11 @@ const ReactSelectStyled = forwardRef((props: SelectProps, ref: any) => { fontWeight: "normal", fontSize: "0.875rem", }), - input: (base, state) => ({ + input: (base) => ({ ...base, fontSize: "1rem", }), - container: (base, state) => ({ + container: (base) => ({ ...base, fontSize: "0.875rem", }), diff --git a/frontend/components/RecentChecklistList.tsx b/frontend/components/RecentChecklistList.tsx index 3ed4c06f..326d2782 100644 --- a/frontend/components/RecentChecklistList.tsx +++ b/frontend/components/RecentChecklistList.tsx @@ -1,7 +1,7 @@ import React from "react"; import dayjs from "dayjs"; import { dateTimeToRelative } from "lib/helpers"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import Button from "components/Button"; import useFetchRecentChecklists from "hooks/useFetchRecentChecklists"; import useFetchRecentSpecies from "hooks/useFetchRecentSpecies"; diff --git a/frontend/components/RecentSpeciesList.tsx b/frontend/components/RecentSpeciesList.tsx index a08bf99c..a8a2cd3a 100644 --- a/frontend/components/RecentSpeciesList.tsx +++ b/frontend/components/RecentSpeciesList.tsx @@ -2,7 +2,7 @@ import React from "react"; import dayjs from "dayjs"; import useFetchRecentSpecies from "hooks/useFetchRecentSpecies"; import { dateTimeToRelative } from "lib/helpers"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import Icon from "components/Icon"; import Button from "components/Button"; import Alert from "components/Alert"; diff --git a/frontend/components/RequireAuth.tsx b/frontend/components/RequireAuth.tsx new file mode 100644 index 00000000..c952287a --- /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 "hooks/useUser"; +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/components/SpeciesCard.tsx b/frontend/components/SpeciesCard.tsx index 7352859b..21586714 100644 --- a/frontend/components/SpeciesCard.tsx +++ b/frontend/components/SpeciesCard.tsx @@ -1,8 +1,7 @@ import React from "react"; -import Icon from "components/Icon"; import CloseButton from "components/CloseButton"; -import { useTrip } from "providers/trip"; -import { useModal } from "providers/modals"; +import { useTrip } from "hooks/useTrip"; +import { useModal } from "stores/modals"; type Props = { name: string; diff --git a/frontend/components/TargetRow.tsx b/frontend/components/TargetRow.tsx index c419180f..bb09b610 100644 --- a/frontend/components/TargetRow.tsx +++ b/frontend/components/TargetRow.tsx @@ -1,11 +1,11 @@ import React from "react"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import Icon from "components/Icon"; import MonthlyFrequencyChart from "components/MonthlyFrequencyChart"; import useFetchRecentSpecies from "hooks/useFetchRecentSpecies"; import { dateTimeToRelative } from "lib/helpers"; import type { Target } from "@birdplan/shared"; -import { useSpeciesImages } from "providers/species-images"; +import { useSpeciesImages } from "hooks/useSpeciesImages"; import useTripMutation from "hooks/useTripMutation"; import MutualBadge from "components/MutualBadge"; import { useNavigate } from "react-router-dom"; diff --git a/frontend/components/TravelTime.tsx b/frontend/components/TravelTime.tsx index cc83b59c..5fb1559d 100644 --- a/frontend/components/TravelTime.tsx +++ b/frontend/components/TravelTime.tsx @@ -1,4 +1,4 @@ -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import Icon from "components/Icon"; import { PersonStanding, Car, Bike } from "lucide-react"; import { formatTime, formatDistance } from "lib/helpers"; diff --git a/frontend/components/TripNav.tsx b/frontend/components/TripNav.tsx index 96d5f01d..519efac7 100644 --- a/frontend/components/TripNav.tsx +++ b/frontend/components/TripNav.tsx @@ -1,8 +1,8 @@ import React from "react"; import clsx from "clsx"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import { Link, useLocation } from "react-router-dom"; -import { useModal } from "providers/modals"; +import { useModal } from "stores/modals"; import TripOptionsDropdown from "components/TripOptionsDropdown"; import Icon from "components/Icon"; diff --git a/frontend/components/TripOptionsDropdown.tsx b/frontend/components/TripOptionsDropdown.tsx index 5e9593f8..a29c72b7 100644 --- a/frontend/components/TripOptionsDropdown.tsx +++ b/frontend/components/TripOptionsDropdown.tsx @@ -1,8 +1,8 @@ import React from "react"; import { Link } from "react-router-dom"; -import { useModal } from "providers/modals"; -import { useProfile } from "providers/profile"; -import { useTrip } from "providers/trip"; +import { useModal } from "stores/modals"; +import { useUser } from "hooks/useUser"; +import { useTrip } from "hooks/useTrip"; import useTargetView from "hooks/useTargetView"; import { DropdownMenu, @@ -19,7 +19,7 @@ type Props = { export default function TripOptionsDropdown({ className }: Props) { const { open } = useModal(); - const { uid } = useProfile(); + const { user } = useUser(); const { trip, canEdit, participants } = useTrip(); const { view } = useTargetView(trip); @@ -48,7 +48,7 @@ export default function TripOptionsDropdown({ className }: Props) { }, { name: "Export KML", - href: `${import.meta.env.VITE_API_URL}/trips/${trip?._id}/export?uid=${uid}&targets=${view}`, + href: `${import.meta.env.VITE_API_URL}/trips/${trip?._id}/export?userId=${user?._id}&targets=${view}`, icon: , }, { diff --git a/frontend/components/UtilityPage.tsx b/frontend/components/UtilityPage.tsx index f16ff8f7..1bff3dee 100644 --- a/frontend/components/UtilityPage.tsx +++ b/frontend/components/UtilityPage.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Link } from "react-router-dom"; import Logo from "components/Logo"; +import Footer from "components/Footer"; type PropTypes = { heading: React.ReactNode; @@ -11,24 +12,25 @@ type PropTypes = { export default function UtilityPage({ heading, title, children }: PropTypes) { const documentTitle = title ?? (typeof heading === "string" ? heading : undefined); return ( -
- {documentTitle && ( - {documentTitle} - )} -
- - - - {typeof heading === "string" ? ( -

{heading}

- ) : ( -
{heading}
- )} -
+
+
+ {documentTitle && {documentTitle}} +
+ + + + {typeof heading === "string" ? ( +

{heading}

+ ) : ( +
{heading}
+ )} +
-
-
{children}
+
+
{children}
+
+
); } diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 3cd4a8f9..26604a5d 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -21,7 +21,7 @@ export default tseslint.config( ...reactHooks.configs.recommended.rules, "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], "@typescript-eslint/no-empty-object-type": "off", "no-empty": "off", }, 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/useFetchHotspots.ts b/frontend/hooks/useFetchHotspots.ts index 1ce9b199..375609a2 100644 --- a/frontend/hooks/useFetchHotspots.ts +++ b/frontend/hooks/useFetchHotspots.ts @@ -1,7 +1,7 @@ import React from "react"; import { eBirdHotspot } from "@birdplan/shared"; import { getMarkerColorIndex } from "lib/helpers"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import { useQuery } from "@tanstack/react-query"; import useTripMutation from "hooks/useTripMutation"; diff --git a/frontend/hooks/useFetchRecentSpecies.ts b/frontend/hooks/useFetchRecentSpecies.ts index f66ecd0f..f5bdec4a 100644 --- a/frontend/hooks/useFetchRecentSpecies.ts +++ b/frontend/hooks/useFetchRecentSpecies.ts @@ -1,9 +1,9 @@ -import { useProfile } from "providers/profile"; +import { useUser } from "hooks/useUser"; import { RecentSpecies } from "lib/types"; import { useQuery } from "@tanstack/react-query"; export default function useFetchRecentSpecies(region?: string) { - const { lifelist } = useProfile(); + const { lifelist } = useUser(); const { data, isLoading, error, refetch } = useQuery({ queryKey: [`/region/${region}/species`], 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..41d1ea51 --- /dev/null +++ b/frontend/hooks/useLogout.ts @@ -0,0 +1,21 @@ +import React from "react"; +import { mutate } from "lib/http"; +import { teardownSessionForReload } from "lib/logout"; + +export default function useLogout() { + const [loading, setLoading] = React.useState(false); + + const logout = async () => { + setLoading(true); + try { + await mutate("POST", "/auth/logout"); + } catch (error) { + console.error(error); + } finally { + await teardownSessionForReload(); + window.location.href = "/"; + } + }; + + return { logout, loading }; +} diff --git a/frontend/hooks/useRedeemMagicLink.ts b/frontend/hooks/useRedeemMagicLink.ts new file mode 100644 index 00000000..256afb35 --- /dev/null +++ b/frontend/hooks/useRedeemMagicLink.ts @@ -0,0 +1,18 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { RedeemMagicLinkResponse } from "@birdplan/shared"; +import useMutation from "hooks/useMutation"; +import { setSessionToken } from "lib/sessionToken"; + +export default function useRedeemMagicLink() { + const queryClient = useQueryClient(); + + return useMutation({ + url: "/auth/redeem-magic-link", + method: "POST", + showToastError: false, + onSuccess: async (data) => { + setSessionToken(data.token); + await queryClient.invalidateQueries({ queryKey: ["/auth/me"] }); + }, + }); +} diff --git a/frontend/hooks/useReportNoCode.ts b/frontend/hooks/useReportNoCode.ts new file mode 100644 index 00000000..156075d7 --- /dev/null +++ b/frontend/hooks/useReportNoCode.ts @@ -0,0 +1,9 @@ +import useMutation from "hooks/useMutation"; + +export default function useReportNoCode() { + return useMutation<{ ok: boolean }, { email: string }>({ + url: "/auth/otp-not-received", + method: "POST", + showToastError: false, + }); +} 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/providers/species-images.tsx b/frontend/hooks/useSpeciesImages.ts similarity index 50% rename from frontend/providers/species-images.tsx rename to frontend/hooks/useSpeciesImages.ts index b475da39..b7bc82a9 100644 --- a/frontend/providers/species-images.tsx +++ b/frontend/hooks/useSpeciesImages.ts @@ -3,26 +3,7 @@ import { useQuery } from "@tanstack/react-query"; const AVICOMMONS_DOMAIN = "https://static.avicommons.org"; -type ContextT = { - getSpeciesImg: ( - code: string, - size?: "240" | "320" | "480" | "900" - ) => { url: string; by: string | undefined } | undefined; -}; - -const initialState: ContextT = { - getSpeciesImg: () => undefined, -}; - -export const SpeciesImagesContext = React.createContext({ - ...initialState, -}); - -type Props = { - children: React.ReactNode; -}; - -const SpeciesImagesProvider = ({ children }: Props) => { +export const useSpeciesImages = () => { const { data } = useQuery>({ queryKey: ["avicommons"], queryFn: () => fetch("/avicommons.json").then((res) => res.json()), @@ -45,20 +26,5 @@ const SpeciesImagesProvider = ({ children }: Props) => { [data] ); - return ( - - {children} - - ); + return { getSpeciesImg }; }; - -const useSpeciesImages = () => { - const state = React.useContext(SpeciesImagesContext); - return { ...state }; -}; - -export { SpeciesImagesProvider, useSpeciesImages }; diff --git a/frontend/hooks/useTrip.ts b/frontend/hooks/useTrip.ts new file mode 100644 index 00000000..10ff4f5c --- /dev/null +++ b/frontend/hooks/useTrip.ts @@ -0,0 +1,112 @@ +import React from "react"; +import { create } from "zustand"; +import { Trip, ParticipantView } from "@birdplan/shared"; +import { useLocation } from "react-router-dom"; +import { useUser } from "hooks/useUser"; +import { useSessionToken } from "lib/sessionToken"; +import { fullMonths, months, getTripIdFromPath } from "lib/helpers"; +import { useQuery } from "@tanstack/react-query"; + +type SelectedSpecies = { + code: string; + name: string; +}; + +type HaloT = { + lat: number; + lng: number; + color: string; +}; + +type SetState = T | ((prev: T) => T); + +type TripUiState = { + selectedSpecies?: SelectedSpecies; + selectedMarkerId?: string; + halo?: HaloT; + showAllHotspots: boolean; + showSatellite: boolean; + setSelectedSpecies: (species?: SelectedSpecies) => void; + setSelectedMarkerId: (id?: string) => void; + setHalo: (data?: HaloT) => void; + setShowAllHotspots: (show: SetState) => void; + setShowSatellite: (show: SetState) => void; +}; + +const resolve = (value: SetState, prev: T): T => + typeof value === "function" ? (value as (prev: T) => T)(prev) : value; + +const useTripUiStore = create((set) => ({ + showAllHotspots: false, + showSatellite: false, + setSelectedSpecies: (selectedSpecies) => set({ selectedSpecies }), + setSelectedMarkerId: (selectedMarkerId) => set({ selectedMarkerId }), + setHalo: (halo) => set({ halo }), + setShowAllHotspots: (show) => set((s) => ({ showAllHotspots: resolve(show, s.showAllHotspots) })), + setShowSatellite: (show) => set((s) => ({ showSatellite: resolve(show, s.showSatellite) })), +})); + +export const useClearSelectedSpeciesOnNavigate = () => { + const { pathname } = useLocation(); + const setSelectedSpecies = useTripUiStore((s) => s.setSelectedSpecies); + React.useEffect(() => { + return () => setSelectedSpecies(undefined); + }, [pathname, setSelectedSpecies]); +}; + +export const useTrip = () => { + const { pathname } = useLocation(); + const id = getTripIdFromPath(pathname); + + const { + data: trip, + isFetching, + isLoading, + refetch, + } = useQuery({ + queryKey: [`/trips/${id}`], + enabled: !!id, + refetchInterval: 1000 * 60 * 2, + }); + + const { user } = useUser(); + const token = useSessionToken(); + const canEdit = !!trip?.viewer; + const isOwner = !!(user?._id && trip?.ownerId === user._id); + + const { data: participants } = useQuery({ + queryKey: [`/trips/${id}/participants`], + enabled: !!id && !!token && !!trip, + }); + + const ui = useTripUiStore(); + const is404 = !!token && !!id && !trip && !isLoading; + + const dateRangeLabel = + trip?.startMonth && trip?.endMonth + ? trip.startMonth === trip.endMonth + ? fullMonths[trip.startMonth - 1] + : `${months[trip.startMonth - 1]} - ${months[trip.endMonth - 1]}` + : ""; + + return { + trip: trip || null, + isFetching, + participants: participants || null, + selectedSpecies: ui.selectedSpecies, + canEdit, + isOwner, + is404, + selectedMarkerId: ui.selectedMarkerId, + halo: ui.halo, + dateRangeLabel, + showAllHotspots: ui.showAllHotspots, + showSatellite: ui.showSatellite, + setSelectedSpecies: ui.setSelectedSpecies, + setSelectedMarkerId: ui.setSelectedMarkerId, + setHalo: ui.setHalo, + setShowAllHotspots: ui.setShowAllHotspots, + setShowSatellite: ui.setShowSatellite, + refetch, + }; +}; diff --git a/frontend/hooks/useTripLifelist.ts b/frontend/hooks/useTripLifelist.ts index 97d40db0..c22342bf 100644 --- a/frontend/hooks/useTripLifelist.ts +++ b/frontend/hooks/useTripLifelist.ts @@ -1,5 +1,5 @@ import { Trip } from "@birdplan/shared"; -import { useProfile } from "providers/profile"; +import { useUser } from "hooks/useUser"; export type TripLifelist = { lifelist: string[]; @@ -9,7 +9,7 @@ export type TripLifelist = { }; export default function useTripLifelist(trip?: Trip | null): TripLifelist { - const { lifelist: worldLifelist } = useProfile(); + const { lifelist: worldLifelist } = useUser(); const lifelist = trip?.groupLifelist ?? trip?.tripLifelist ?? trip?.viewerLifelist ?? worldLifelist; const myLifelist = trip?.viewer?.listMode === "custom" ? trip?.viewerLifelist ?? worldLifelist : worldLifelist; diff --git a/frontend/hooks/useTripMutation.ts b/frontend/hooks/useTripMutation.ts index 2858d991..2318e516 100644 --- a/frontend/hooks/useTripMutation.ts +++ b/frontend/hooks/useTripMutation.ts @@ -2,7 +2,7 @@ import toast from "react-hot-toast"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { mutate } from "lib/http"; import { Trip } from "@birdplan/shared"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; type Options = { url: string; diff --git a/frontend/hooks/useUser.ts b/frontend/hooks/useUser.ts new file mode 100644 index 00000000..93020a44 --- /dev/null +++ b/frontend/hooks/useUser.ts @@ -0,0 +1,24 @@ +import React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { User } from "@birdplan/shared"; +import { useSessionToken } from "lib/sessionToken"; + +export const useUser = () => { + const token = useSessionToken(); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ["/auth/me"], + enabled: !!token, + }); + + const user = token ? data ?? null : null; + const loading = !!token && isLoading; + const lifelist = user?.lifelist?.filter((it) => !user.exceptions?.includes(it)) ?? []; + + const refreshUser = React.useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: ["/auth/me"] }); + }, [queryClient]); + + return { user, lifelist, loading, refreshUser }; +}; diff --git a/frontend/hooks/useVerifyCode.ts b/frontend/hooks/useVerifyCode.ts new file mode 100644 index 00000000..4bd9ca3b --- /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 }; +export type VerifyCodeInput = { email: string; code: 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/avatar.ts b/frontend/lib/avatar.ts index 0cb41d87..7499ac43 100644 --- a/frontend/lib/avatar.ts +++ b/frontend/lib/avatar.ts @@ -1,4 +1,4 @@ -import type { ParticipantView, Profile } from "@birdplan/shared"; +import type { ParticipantView, User } from "@birdplan/shared"; export type AvatarUser = { seed: string; @@ -7,18 +7,18 @@ export type AvatarUser = { photoUrl?: string | null; }; -export function avatarFromProfile(profile: Pick): AvatarUser { +export function avatarFromUser(user: Pick): AvatarUser { return { - seed: profile.uid, - name: profile.name, - email: profile.email, - photoUrl: profile.photoUrl, + seed: user._id, + name: user.name, + email: user.email, + photoUrl: user.photoUrl, }; } export function avatarFromParticipant(p: ParticipantView): AvatarUser { return { - seed: p.uid || p._id, + seed: p.userId || p._id, name: p.name, email: p.email, photoUrl: p.photoUrl, 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/helpers.ts b/frontend/lib/helpers.ts index f5e39418..70ececb1 100644 --- a/frontend/lib/helpers.ts +++ b/frontend/lib/helpers.ts @@ -212,6 +212,7 @@ const RESERVED_ROUTES = new Set([ "import-lifelist", "accept", "admin", + "magic", ]); export function getTripIdFromPath(pathname: string): string | undefined { diff --git a/frontend/lib/http.ts b/frontend/lib/http.ts index 1b519a1d..c853e790 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, @@ -35,9 +36,12 @@ export const get = async (url: string, params: Params, showLoading?: boolean) => try { json = await res.json(); - } catch (error) {} + } catch {} 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, @@ -63,10 +66,13 @@ export const mutate = async (method: "POST" | "PUT" | "DELETE" | "PATCH", url: s let json: any | null = null; try { json = await res.json(); - } catch (error) {} + } catch {} 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/icons.ts b/frontend/lib/icons.ts index 66a831fa..c43a959e 100644 --- a/frontend/lib/icons.ts +++ b/frontend/lib/icons.ts @@ -269,13 +269,6 @@ export const icons = { }, }; -type MarkerIconsT = { - [key: string]: { - icon: IconNameT; - color: string; - }; -}; - const markerColos = { sky: "#0284c7", black: "#334155", diff --git a/frontend/lib/logout.ts b/frontend/lib/logout.ts new file mode 100644 index 00000000..424088b9 --- /dev/null +++ b/frontend/lib/logout.ts @@ -0,0 +1,16 @@ +import * as idbKeyval from "idb-keyval"; +import type { QueryClient } from "@tanstack/react-query"; +import { clearSessionToken, clearSessionTokenStorage } 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); +} + +export async function teardownSessionForReload() { + clearSessionTokenStorage(); + await idbKeyval.del(IDB_CACHE_KEY); +} diff --git a/frontend/lib/sessionToken.ts b/frontend/lib/sessionToken.ts new file mode 100644 index 00000000..0e053d46 --- /dev/null +++ b/frontend/lib/sessionToken.ts @@ -0,0 +1,31 @@ +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); + +export const clearSessionTokenStorage = () => { + current = null; + if (typeof localStorage !== "undefined") localStorage.removeItem(TOKEN_KEY); +}; + +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/AddHotspot.tsx b/frontend/modals/AddHotspot.tsx index 94ec47fa..d3b70090 100644 --- a/frontend/modals/AddHotspot.tsx +++ b/frontend/modals/AddHotspot.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { Header, Body } from "providers/modals"; +import { Header, Body } from "components/Modal"; import Input from "components/Input"; -import { useModal } from "providers/modals"; +import { useModal } from "stores/modals"; import useFetchHotspots from "hooks/useFetchHotspots"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import { eBirdHotspot, HotspotInput } from "@birdplan/shared"; import Icon from "components/Icon"; import clsx from "clsx"; diff --git a/frontend/modals/AddItineraryLocation.tsx b/frontend/modals/AddItineraryLocation.tsx index 87c5bfbf..83286c91 100644 --- a/frontend/modals/AddItineraryLocation.tsx +++ b/frontend/modals/AddItineraryLocation.tsx @@ -1,8 +1,8 @@ import React from "react"; -import { Header, Body } from "providers/modals"; -import { useTrip } from "providers/trip"; +import { Header, Body } from "components/Modal"; +import { useTrip } from "hooks/useTrip"; import MarkerWithIcon from "components/MarkerWithIcon"; -import { useModal } from "providers/modals"; +import { useModal } from "stores/modals"; import useTripMutation from "hooks/useTripMutation"; import { nanoId } from "lib/helpers"; import { MarkerIconT } from "lib/icons"; diff --git a/frontend/modals/AddParticipant.tsx b/frontend/modals/AddParticipant.tsx index 5211f963..8b5f3676 100644 --- a/frontend/modals/AddParticipant.tsx +++ b/frontend/modals/AddParticipant.tsx @@ -2,8 +2,9 @@ import React from "react"; import toast from "react-hot-toast"; import clsx from "clsx"; import { useQueryClient } from "@tanstack/react-query"; -import { Header, Body, Footer, useModal } from "providers/modals"; -import { useTrip } from "providers/trip"; +import { Header, Body, Footer } from "components/Modal"; +import { useModal } from "stores/modals"; +import { useTrip } from "hooks/useTrip"; import useMutation from "hooks/useMutation"; import Button from "components/Button"; import Input from "components/Input"; diff --git a/frontend/modals/AddPlace.tsx b/frontend/modals/AddPlace.tsx index 978e74ac..862fdd08 100644 --- a/frontend/modals/AddPlace.tsx +++ b/frontend/modals/AddPlace.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { Header, Body } from "providers/modals"; +import { Header, Body } from "components/Modal"; import Button from "components/Button"; import Field from "components/Field"; -import { useModal } from "providers/modals"; -import { useTrip } from "providers/trip"; +import { useModal } from "stores/modals"; +import { useTrip } from "hooks/useTrip"; import { nanoId } from "lib/helpers"; import { CustomMarker, GooglePlaceT } from "lib/types"; import MarkerWithIcon from "components/MarkerWithIcon"; diff --git a/frontend/modals/DeleteAccount.tsx b/frontend/modals/DeleteAccount.tsx index 073d7052..845033dd 100644 --- a/frontend/modals/DeleteAccount.tsx +++ b/frontend/modals/DeleteAccount.tsx @@ -1,16 +1,18 @@ import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Header, Body, Footer, useModal } from "providers/modals"; +import { useQueryClient } from "@tanstack/react-query"; +import { Header, Body, Footer } from "components/Modal"; +import { useModal } from "stores/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 +22,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/modals/GenerateMagicLink.tsx b/frontend/modals/GenerateMagicLink.tsx new file mode 100644 index 00000000..20d47285 --- /dev/null +++ b/frontend/modals/GenerateMagicLink.tsx @@ -0,0 +1,105 @@ +import React, { useState } from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import toast from "react-hot-toast"; +import { CopyIcon, CheckIcon } from "lucide-react"; +import { GenerateMagicLinkResponse } from "@birdplan/shared"; +import { Header, Body } from "components/Modal"; +import { mutate } from "lib/http"; +import Button from "components/Button"; +import Icon from "components/Icon"; + +dayjs.extend(relativeTime); + +function CopyLinkField({ url }: { url: string }) { + const [copied, setCopied] = useState(false); + + const copy = async () => { + await navigator.clipboard.writeText(url); + setCopied(true); + toast.success("Link copied"); + }; + + return ( +
+ e.target.select()} + className="min-w-0 flex-1 rounded border border-gray-200 bg-gray-50 px-2.5 py-1.5 font-mono text-xs text-gray-700" + /> + +
+ ); +} + +function MagicLinkDetails({ link, email }: { link: GenerateMagicLinkResponse; email?: string }) { + return ( +
+ {link.isNewUser ? ( +

+ A new account was created for {link.email}. +

+ ) : ( +

Send this link to {email || "the user"}.

+ )} + +

Single-use Ā· expires {dayjs(link.expiresAt).fromNow()}

+
+ ); +} + +type Props = { link?: GenerateMagicLinkResponse; email?: string }; + +export default function GenerateMagicLink({ link: initialLink, email }: Props) { + const [input, setInput] = useState(email ?? ""); + const [generating, setGenerating] = useState(false); + const [link, setLink] = useState(initialLink ?? null); + + const generate = async (e: React.FormEvent) => { + e.preventDefault(); + const target = input.trim().toLowerCase(); + if (!target) return; + setGenerating(true); + try { + const res = await mutate("POST", "/admin/magic-link", { email: target }); + setLink(res as GenerateMagicLinkResponse); + } catch (err: any) { + toast.error(err.message || "Failed to generate link"); + } finally { + setGenerating(false); + } + }; + + return ( + <> +
{link ? "Magic sign-in link" : "Generate magic link"}
+ + {link ? ( + + ) : ( +
+

+ Enter an email to create a sign-in link. If no account exists, one is created. +

+ setInput(e.target.value)} + className="w-full rounded border border-gray-200 px-2.5 py-1.5 text-sm" + /> + +
+ )} + + + ); +} diff --git a/frontend/modals/Hotspot.tsx b/frontend/modals/Hotspot.tsx index 19e4150b..62f22fe2 100644 --- a/frontend/modals/Hotspot.tsx +++ b/frontend/modals/Hotspot.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { Body } from "providers/modals"; +import { Body } from "components/Modal"; import { HotspotInput, Hotspot as HotspotT, Trip } from "@birdplan/shared"; import Button from "components/Button"; import toast from "react-hot-toast"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import DirectionsButton from "components/DirectionsButton"; import { isRegionEnglish, getMarkerColor } from "lib/helpers"; import RecentSpeciesList from "components/RecentSpeciesList"; @@ -63,7 +63,7 @@ export default function Hotspot({ hotspot }: Props) { const removeMutation = useTripMutation({ url: `/trips/${trip?._id}/hotspots/${id}`, method: "DELETE", - updateCache: (old, input) => ({ + updateCache: (old) => ({ ...old, hotspots: old.hotspots.filter((it) => it.id !== id), }), diff --git a/frontend/modals/InviteAsEditor.tsx b/frontend/modals/InviteAsEditor.tsx index 55789be7..2944b8fc 100644 --- a/frontend/modals/InviteAsEditor.tsx +++ b/frontend/modals/InviteAsEditor.tsx @@ -1,8 +1,9 @@ import React from "react"; import toast from "react-hot-toast"; import { useQueryClient } from "@tanstack/react-query"; -import { Header, Body, Footer, useModal } from "providers/modals"; -import { useTrip } from "providers/trip"; +import { Header, Body, Footer } from "components/Modal"; +import { useModal } from "stores/modals"; +import { useTrip } from "hooks/useTrip"; import useMutation from "hooks/useMutation"; import Button from "components/Button"; import Input from "components/Input"; diff --git a/frontend/modals/ManageLifelist.tsx b/frontend/modals/ManageLifelist.tsx index 04f8db87..567fc7c3 100644 --- a/frontend/modals/ManageLifelist.tsx +++ b/frontend/modals/ManageLifelist.tsx @@ -2,8 +2,9 @@ import React from "react"; import { Link, useLocation } from "react-router-dom"; import toast from "react-hot-toast"; import { useQueryClient } from "@tanstack/react-query"; -import { Header, Body, Footer, useModal } from "providers/modals"; -import { useTrip } from "providers/trip"; +import { Header, Body, Footer } from "components/Modal"; +import { useModal } from "stores/modals"; +import { useTrip } from "hooks/useTrip"; import useMutation from "hooks/useMutation"; import LifelistEditor from "components/LifelistEditor"; import LifelistField from "components/LifelistField"; @@ -27,7 +28,7 @@ export default function ManageLifelist({ participantId }: Props) { const isSelf = !!p?.isMe; const isPending = p?.status === "pending"; - const isNameOnly = !!p && !p.uid && !isPending; + const isNameOnly = !!p && !p.userId && !isPending; const isGroup = (participants?.length ?? 0) > 1; const [nameDraft, setNameDraft] = React.useState(p?.name || ""); diff --git a/frontend/modals/Marker.tsx b/frontend/modals/Marker.tsx index acda982a..9da24bae 100644 --- a/frontend/modals/Marker.tsx +++ b/frontend/modals/Marker.tsx @@ -1,10 +1,10 @@ import React from "react"; -import { Header, Body } from "providers/modals"; +import { Header, Body } from "components/Modal"; import Button from "components/Button"; import Field from "components/Field"; import Input from "components/Input"; -import { useModal } from "providers/modals"; -import { useTrip } from "providers/trip"; +import { useModal } from "stores/modals"; +import { useTrip } from "hooks/useTrip"; import { nanoId } from "lib/helpers"; import { MarkerIconT, markerIcons } from "lib/icons"; import MarkerWithIcon from "components/MarkerWithIcon"; diff --git a/frontend/modals/OpenBirding.tsx b/frontend/modals/OpenBirding.tsx index 4330195f..2b43e436 100644 --- a/frontend/modals/OpenBirding.tsx +++ b/frontend/modals/OpenBirding.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { Header, Body, Footer, useModal } from "providers/modals"; +import { Header, Body, Footer } from "components/Modal"; +import { useModal } from "stores/modals"; import Button from "components/Button"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import useMutation from "hooks/useMutation"; export default function OpenBirding() { diff --git a/frontend/modals/PersonalLocation.tsx b/frontend/modals/PersonalLocation.tsx index e1744d55..9e9f92f2 100644 --- a/frontend/modals/PersonalLocation.tsx +++ b/frontend/modals/PersonalLocation.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Header, Body } from "providers/modals"; +import { Header, Body } from "components/Modal"; import { Hotspot as HotspotT } from "@birdplan/shared"; import ObsList from "components/ObsList"; import DirectionsButton from "components/DirectionsButton"; @@ -10,7 +10,7 @@ import { DropdownMenuTrigger, } from "components/ui/dropdown-menu"; import Icon from "components/Icon"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; type Props = { hotspot: HotspotT; 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]/index.tsx b/frontend/pages/[tripId]/index.tsx index 86ddaceb..fca0ff7a 100644 --- a/frontend/pages/[tripId]/index.tsx +++ b/frontend/pages/[tripId]/index.tsx @@ -1,14 +1,14 @@ import React from "react"; import Header from "components/Header"; import MapBox from "components/Mapbox"; -import { useModal } from "providers/modals"; +import { useModal } from "stores/modals"; import useFetchHotspots from "hooks/useFetchHotspots"; import { getMarkerColorIndex } from "lib/helpers"; import toast from "react-hot-toast"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import CloseButton from "components/CloseButton"; import TripNav from "components/TripNav"; -import { useUser } from "providers/user"; +import { useUser } from "hooks/useUser"; import ErrorBoundary from "components/ErrorBoundary"; import MapButton from "components/MapButton"; import Icon from "components/Icon"; @@ -68,7 +68,7 @@ export default function Trip() { {`${trip.name} | BirdPlan.app`} )} -
+
diff --git a/frontend/pages/[tripId]/itinerary.tsx b/frontend/pages/[tripId]/itinerary.tsx index 83e96063..3edde419 100644 --- a/frontend/pages/[tripId]/itinerary.tsx +++ b/frontend/pages/[tripId]/itinerary.tsx @@ -1,13 +1,13 @@ import React from "react"; import Header from "components/Header"; import TripNav from "components/TripNav"; -import { useUser } from "providers/user"; +import { useUser } from "hooks/useUser"; import ErrorBoundary from "components/ErrorBoundary"; import Input from "components/Input"; import Button from "components/Button"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import toast from "react-hot-toast"; -import { useModal } from "providers/modals"; +import { useModal } from "stores/modals"; import Icon from "components/Icon"; import NotFound from "components/NotFound"; import useTripMutation from "hooks/useTripMutation"; @@ -77,7 +77,7 @@ export default function Itinerary() { {`${trip.name} | BirdPlan.app`} )} -
+
diff --git a/frontend/pages/[tripId]/lifelist.tsx b/frontend/pages/[tripId]/lifelist.tsx index aeb8cf6c..210fdee9 100644 --- a/frontend/pages/[tripId]/lifelist.tsx +++ b/frontend/pages/[tripId]/lifelist.tsx @@ -6,9 +6,8 @@ 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 { useTrip } from "hooks/useTrip"; import useLifelistMode from "hooks/useLifelistMode"; export default function TripLifelist() { @@ -58,7 +57,6 @@ export default function TripLifelist() {
-
); } diff --git a/frontend/pages/[tripId]/participants.tsx b/frontend/pages/[tripId]/participants.tsx index 02defbc4..25b4cffa 100644 --- a/frontend/pages/[tripId]/participants.tsx +++ b/frontend/pages/[tripId]/participants.tsx @@ -7,10 +7,9 @@ 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"; +import { useTrip } from "hooks/useTrip"; +import { useModal } from "stores/modals"; import { getReturnLabel } from "lib/helpers"; export default function TripParticipants() { @@ -104,7 +103,6 @@ export default function TripParticipants() {
-
); } diff --git a/frontend/pages/[tripId]/settings.tsx b/frontend/pages/[tripId]/settings.tsx index 8d3ea6c4..d0e292e3 100644 --- a/frontend/pages/[tripId]/settings.tsx +++ b/frontend/pages/[tripId]/settings.tsx @@ -1,9 +1,8 @@ import React from "react"; import Header from "components/Header"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import toast from "react-hot-toast"; import MonthSelect from "components/MonthSelect"; -import LoginModal from "components/LoginModal"; import Footer from "components/Footer"; import { Option } from "lib/types"; import Field from "components/Field"; @@ -170,7 +169,6 @@ function SettingsForm({ trip, initialRegion, isOwner }: SettingsFormProps) {
-
); } diff --git a/frontend/pages/[tripId]/targets.tsx b/frontend/pages/[tripId]/targets.tsx index 1d2b7a0f..20208a0c 100644 --- a/frontend/pages/[tripId]/targets.tsx +++ b/frontend/pages/[tripId]/targets.tsx @@ -1,16 +1,15 @@ import React from "react"; import Header from "components/Header"; import MapBox from "components/Mapbox"; -import { useModal } from "providers/modals"; +import { useModal } from "stores/modals"; import useFetchSpeciesObs from "hooks/useFetchSpeciesObs"; import toast from "react-hot-toast"; -import { useTrip } from "providers/trip"; +import { useTrip } from "hooks/useTrip"; import SpeciesCard from "components/SpeciesCard"; import Card from "components/Card"; import Button from "components/Button"; import TripNav from "components/TripNav"; -import { useUser } from "providers/user"; -import Input from "components/Input"; +import { useUser } from "hooks/useUser"; import ErrorBoundary from "components/ErrorBoundary"; import useTargetView from "hooks/useTargetView"; import useMutualTargets from "hooks/useMutualTargets"; @@ -110,7 +109,7 @@ export default function TripTargets() {
@@ -129,18 +128,6 @@ export default function TripTargets() { )} {!isLoadingTargets && !!trip && ( <> - {!!regionData?.items?.length && ( -

- Found{" "} - - {filteredTargets?.length} - {" "} - species above{" "} - - {minPercent}% - -

- )}
+ {!!regionData?.items?.length && ( +

+ Found{" "} + + {filteredTargets?.length} + {" "} + species above{" "} + + {minPercent}% + +

+ )} )} {targetsError && ( diff --git a/frontend/pages/[tripId]/targets/[speciesCode].tsx b/frontend/pages/[tripId]/targets/[speciesCode].tsx index 5a369f6f..a755ebfe 100644 --- a/frontend/pages/[tripId]/targets/[speciesCode].tsx +++ b/frontend/pages/[tripId]/targets/[speciesCode].tsx @@ -16,12 +16,12 @@ import Card from "components/Card"; import SpeciesHero from "components/SpeciesHero"; import SpeciesHotspotToolbar, { type Scope, type SortKey } from "components/SpeciesHotspotToolbar"; import SpeciesHotspotList, { type HotspotItem, type MonthMode } from "components/SpeciesHotspotList"; -import { useTrip } from "providers/trip"; -import { useUser } from "providers/user"; +import { useTrip } from "hooks/useTrip"; +import { useUser } from "hooks/useUser"; import useTripLifelist from "hooks/useTripLifelist"; import useMutualTargets from "hooks/useMutualTargets"; -import { useSpeciesImages } from "providers/species-images"; -import { useModal } from "providers/modals"; +import { useSpeciesImages } from "hooks/useSpeciesImages"; +import { useModal } from "stores/modals"; import useDownloadTargets from "hooks/useDownloadTargets"; import useFetchSpeciesObs from "hooks/useFetchSpeciesObs"; import useTripMutation from "hooks/useTripMutation"; @@ -30,7 +30,7 @@ import { OPENBIRDING_API_URL } from "lib/config"; import { dateTimeToRelative } from "lib/helpers"; import { getMonthRange } from "lib/targets"; import { useSpeciesHotspotPreferences } from "stores/speciesHotspotPreferences"; -import type { OpenBirdingHotspotRankingResponse, Profile } from "@birdplan/shared"; +import type { OpenBirdingHotspotRankingResponse, User } from "@birdplan/shared"; export default function SpeciesDetail() { const { speciesCode = "" } = useParams(); @@ -93,13 +93,13 @@ export default function SpeciesDetail() { url: `/profile/lifelist/add`, method: "POST", onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [`/profile`] }); + queryClient.invalidateQueries({ queryKey: ["/auth/me"] }); queryClient.invalidateQueries({ queryKey: [`/trips/${trip?._id}`] }); }, onMutate: async (data: any) => { - await queryClient.cancelQueries({ queryKey: ["/profile"] }); - const prevData = queryClient.getQueryData([`/profile`]); - queryClient.setQueryData([`/profile`], (old) => + await queryClient.cancelQueries({ queryKey: ["/auth/me"] }); + const prevData = queryClient.getQueryData(["/auth/me"]); + queryClient.setQueryData(["/auth/me"], (old) => old ? { ...old, @@ -110,7 +110,7 @@ export default function SpeciesDetail() { ); return { prevData }; }, - onError: (_e: any, _d: any, ctx: any) => queryClient.setQueryData([`/profile`], ctx?.prevData), + onError: (_e: any, _d: any, ctx: any) => queryClient.setQueryData(["/auth/me"], ctx?.prevData), }); const customSeenMutation = useTripMutation<{ code: string }>({ @@ -306,7 +306,7 @@ export default function SpeciesDetail() { {trip && speciesName && ( {`${speciesName} | ${trip.name} | BirdPlan.app`} )} -
+
diff --git a/frontend/pages/accept/[inviteId].tsx b/frontend/pages/accept/[inviteId].tsx index bdf955e5..4f2de67c 100644 --- a/frontend/pages/accept/[inviteId].tsx +++ b/frontend/pages/accept/[inviteId].tsx @@ -1,15 +1,14 @@ import React from "react"; import UtilityPage from "components/UtilityPage"; -import LoginForm from "components/LoginForm"; -import SignupForm from "components/SignupForm"; import AcceptError from "components/AcceptError"; import Button from "components/Button"; import Icon from "components/Icon"; -import { useUser } from "providers/user"; +import { useUser } from "hooks/useUser"; import { useNavigate, useParams } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Profile, InviteInfo } from "@birdplan/shared"; +import { AcceptInviteResponse, InviteInfo } from "@birdplan/shared"; import useMutation from "hooks/useMutation"; +import { setSessionToken } from "lib/sessionToken"; import { withReturnTo } from "lib/helpers"; export default function Accept() { @@ -17,13 +16,6 @@ export default function Accept() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { inviteId } = useParams(); - const uid = user?.uid; - const firedRef = React.useRef(false); - - const { isSuccess: profileLoaded } = useQuery({ - queryKey: ["/profile"], - enabled: !!uid, - }); const { data: invite, @@ -33,34 +25,32 @@ export default function Accept() { isFetching: inviteFetching, } = useQuery({ queryKey: [`/participants/${inviteId}/invite`], - enabled: !!inviteId && !uid, + enabled: !!inviteId, retry: false, }); - const acceptMutation = useMutation({ + const acceptMutation = useMutation({ url: `/participants/${inviteId}/accept`, - method: "PATCH", + method: "POST", showToastError: false, - onSuccess: (data: any) => { - const profile = queryClient.getQueryData(["/profile"]); - const dest = `/${data?.tripId}/lifelist?from=accept`; - if (profile?.lifelist?.length) { - navigate(dest); - } else { + onSuccess: async (data) => { + if (data.token) { + setSessionToken(data.token); + await queryClient.invalidateQueries({ queryKey: ["/auth/me"] }); + } + const dest = `/${data.tripId}/lifelist?from=accept`; + if (!data.hasName) { + navigate(withReturnTo("/onboarding", dest)); + } else if (!data.hasLifelist) { navigate(`${withReturnTo("/import-lifelist", dest)}&onboarding=1`); + } else { + navigate(dest); } }, }); - React.useEffect(() => { - if (!uid || !inviteId || !profileLoaded || firedRef.current) return; - firedRef.current = true; - acceptMutation.mutate({}); - }, [uid, inviteId, profileLoaded]); - - const retry = () => acceptMutation.mutate({}); + const accept = () => acceptMutation.mutate({}); - const method = invite?.method; const inviteLoading = !invite && !inviteIsError; const heading = invite ? ( @@ -74,13 +64,17 @@ export default function Accept() { "Accept Invite" ); - return ( - - {loading || (!uid && inviteLoading) ? ( + const renderBody = () => { + if (loading || inviteLoading) { + return (
- ) : !uid && inviteIsError ? ( + ); + } + + if (inviteIsError) { + return ( - ) : !uid && invite && invite.status !== "pending" ? ( - - - ) : !uid ? ( - method === "login" ? ( - - ) : ( - - ) - ) : acceptMutation.isError ? ( + ); + } + + if (acceptMutation.isError) { + return ( - ) : ( -
- One moment... -
- )} + ); + } + + return ( +
+ {!user && ( +

+ Accept as {invite.email}. +

+ )} + +
+ ); + }; + + return ( + + {renderBody()} ); } diff --git a/frontend/pages/account.tsx b/frontend/pages/account.tsx index 2529328e..c421bdc1 100644 --- a/frontend/pages/account.tsx +++ b/frontend/pages/account.tsx @@ -1,26 +1,17 @@ import React from "react"; import Header from "components/Header"; import Footer from "components/Footer"; -import LoginModal from "components/LoginModal"; -import { useUser } from "providers/user"; -import { useModal } from "providers/modals"; +import { useUser } from "hooks/useUser"; +import { useModal } from "stores/modals"; import Icon from "components/Icon"; import Avatar from "components/Avatar"; -import { avatarFromProfile } from "lib/avatar"; +import { avatarFromUser } from "lib/avatar"; import Button from "components/Button"; import clsx from "clsx"; import { useState } from "react"; import { IconNameT } from "lib/icons"; -import PasswordChangeForm from "components/PasswordChangeForm"; import EmailChangeForm from "components/EmailChangeForm"; import { Link } from "react-router-dom"; -import Alert from "components/Alert"; -import { useProfile } from "providers/profile"; - -const providerNames = { - "google.com": "Google", - "apple.com": "Apple", -}; type TabItem = { id: string; @@ -30,29 +21,21 @@ type TabItem = { const tabs: TabItem[] = [ { id: "profile", icon: "user", label: "Account" }, - { id: "email", icon: "envelope", label: "Email" }, - { id: "password", icon: "lock", label: "Password" }, + { id: "email", icon: "envelope", label: "Change Email" }, { id: "delete", icon: "warning", label: "Danger Zone" }, ]; export default function Account() { const { user, loading } = useUser(); - const profile = useProfile(); const { open } = useModal(); const [activeTab, setActiveTab] = useState("profile"); if (loading) return
Loading...
; if (!user) return null; - const socialProviders = user.providerData - .filter((provider) => provider.providerId !== "password") - .map((provider) => providerNames[provider.providerId as keyof typeof providerNames]); - - const isEmailProvider = user.providerData.some((provider) => provider.providerId === "password"); - return (
- My Account | BirdPlan.app + My Account | BirdPlan.app
@@ -90,17 +73,12 @@ export default function Account() {

Account

- +
-

{profile.name}

- {profile.email &&

{profile.email}

} +

{user.name}

+ {user.email &&

{user.email}

}
- {socialProviders.length > 0 && ( -

- You logged in using your {socialProviders.join(", ")} account. -

- )}
)} @@ -108,28 +86,7 @@ export default function Account() { {activeTab === "email" && (

Change Email

- - {!isEmailProvider ? ( - - You cannot change your email because you logged in using {socialProviders.join(", ")}. - - ) : ( - - )} -
- )} - - {activeTab === "password" && ( -
-

Change Password

- - {isEmailProvider ? ( - - ) : ( - - Your account is managed through your {socialProviders.join(", ")} account. - - )} +
)} @@ -151,7 +108,6 @@ export default function Account() {
- ); } diff --git a/frontend/pages/admin.tsx b/frontend/pages/admin.tsx index 8e4d3242..6f580988 100644 --- a/frontend/pages/admin.tsx +++ b/frontend/pages/admin.tsx @@ -1,36 +1,27 @@ import React from "react"; import { Navigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; +import toast from "react-hot-toast"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import clsx from "clsx"; -import { AdminDashboard, AdminDashboardUser } from "@birdplan/shared"; +import { AdminDashboard, AdminDashboardUser, AdminDashboardLog, GenerateMagicLinkResponse } from "@birdplan/shared"; import Header from "components/Header"; import Footer from "components/Footer"; -import LoginModal from "components/LoginModal"; import Icon from "components/Icon"; import Avatar from "components/Avatar"; import Card from "components/Card"; import Error from "components/Error"; -import { avatarFromProfile } from "lib/avatar"; -import { useUser } from "providers/user"; -import { useProfile } from "providers/profile"; +import Button from "components/Button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "components/ui/dropdown-menu"; +import { mutate } from "lib/http"; +import { avatarFromUser } from "lib/avatar"; +import { useUser } from "hooks/useUser"; +import { useModal } from "stores/modals"; dayjs.extend(relativeTime); -const providerLabels: Record = { - password: "Email", - "google.com": "Google", - "apple.com": "Apple", -}; - -const providerColors: Record = { - password: "bg-gray-100 text-gray-600", - "google.com": "bg-red-50 text-red-600", - "apple.com": "bg-gray-800 text-white", -}; - -type SortKey = "lastActiveAt" | "createdAt"; +type SortKey = "lastActiveAt" | "lastAuthenticatedAt" | "createdAt"; const formatDate = (value: string | Date | null) => { if (!value) return "—"; @@ -39,6 +30,25 @@ const formatDate = (value: string | Date | null) => { return `${date.format("MMM D, YYYY")} Ā· ${date.fromNow()}`; }; +function LogRow({ log }: { log: AdminDashboardLog }) { + const ip = log.ip === "unknown" ? "IP Unknown" : log.ip; + const detail = [log.email, log.userId, ip].filter(Boolean).join(" Ā· "); + const data = log.data && Object.keys(log.data).length > 0 ? JSON.stringify(log.data) : null; + return ( + + + {log.type} + + + {detail &&

{detail}

} + {data &&

{data}

} + {!detail && !data && —} + + {formatDate(log.createdAt)} + + ); +} + function Stat({ label, value }: { label: string; value: number }) { return (
@@ -65,7 +75,10 @@ function SortHeader({ return ( ); } export default function Admin() { const { user, loading } = useUser(); - const profile = useProfile(); + const [tab, setTab] = React.useState<"users" | "logs">("users"); const [sortKey, setSortKey] = React.useState("lastActiveAt"); const [sortDir, setSortDir] = React.useState<"asc" | "desc">("desc"); const { data, isLoading, error } = useQuery({ queryKey: ["/admin"], - enabled: !!profile.isAdmin, + enabled: !!user?.isAdmin, }); if (loading) return null; - if (!user) return ; - if (!profile.uid) return null; - if (!profile.isAdmin) return ; + if (!user) return null; + if (!user.isAdmin) return ; const handleSort = (key: SortKey) => { if (key === sortKey) { @@ -163,52 +198,116 @@ export default function Admin() {
- -
- - - - - - - - - - - {sortedUsers.map((u) => ( - - - - - +
+ {(["users", "logs"] as const).map((key) => ( + + ))} +
+ + {tab === "users" && ( + <> +
+ +
+ +
+
UserSign-in
-
- -
-

{u.name || "Unnamed"}

- {u.email &&

{u.email}

} -
-
-
- - {formatDate(u.lastActiveAt)}{formatDate(u.createdAt)}
+ + + + + + + + + + {sortedUsers.map((u) => ( + + + + + + + + ))} + +
User +
+
+ +
+

{u.name || "Unnamed"}

+ {u.email &&

{u.email}

} +
+
+
+ {formatDate(u.lastActiveAt)} + + {formatDate(u.lastAuthenticatedAt)} + {formatDate(u.createdAt)} + +
+
+
+ + )} + + {tab === "logs" && ( + +
+ + + + + + - ))} - -
TypeDetailsWhen
-
-
+ + + {(data.logs || []).map((log) => ( + + ))} + {(data.logs || []).length === 0 && ( + + + No logs yet + + + )} + + + + + )} ) )} diff --git a/frontend/pages/support.tsx b/frontend/pages/contact.tsx similarity index 91% rename from frontend/pages/support.tsx rename to frontend/pages/contact.tsx index 92ad60c7..112680b8 100644 --- a/frontend/pages/support.tsx +++ b/frontend/pages/contact.tsx @@ -1,7 +1,7 @@ import React from "react"; import Footer from "components/Footer"; import HomeHeader from "components/HomeHeader"; -import { useUser } from "providers/user"; +import { useUser } from "hooks/useUser"; import toast from "react-hot-toast"; import Field from "components/Field"; import Input from "components/Input"; @@ -10,12 +10,12 @@ import Icon from "components/Icon"; import useMutation from "hooks/useMutation"; import { Link } from "react-router-dom"; -export default function Support() { +export default function Contact() { const { user } = useUser(); const [submitted, setSubmitted] = React.useState(false); const mutation = useMutation({ - url: "/support", + url: "/contact", method: "POST", onSuccess: () => { setSubmitted(true); @@ -40,7 +40,7 @@ export default function Support() { userAgent: navigator.userAgent, screenWidth: window.innerWidth, screenHeight: window.innerHeight, - userId: user?.uid || "not logged in", + userId: user?._id || "not logged in", }; mutation.mutate({ @@ -54,12 +54,12 @@ export default function Support() { return (
- Support | BirdPlan.app + Contact | BirdPlan.app
-

Support

+

Contact

{submitted ? (
@@ -68,10 +68,10 @@ export default function Support() { We've received your request and will get back to you as soon as possible.

- {user?.uid ? "← Back to trips" : "← Back to home"} + {user?._id ? "← Back to trips" : "← Back to home"}
) : ( @@ -81,7 +81,7 @@ export default function Support() {

- + diff --git a/frontend/pages/create.tsx b/frontend/pages/create.tsx index ea4fcdaf..8b4d4766 100644 --- a/frontend/pages/create.tsx +++ b/frontend/pages/create.tsx @@ -5,13 +5,12 @@ import Header from "components/Header"; import Button from "components/Button"; import Footer from "components/Footer"; import MonthSelect from "components/MonthSelect"; -import LoginModal from "components/LoginModal"; import Icon from "components/Icon"; import Field from "components/Field"; import Input from "components/Input"; import { Option } from "lib/types"; import { TripInput } from "@birdplan/shared"; -import { useModal } from "providers/modals"; +import { useModal } from "stores/modals"; import dayjs from "dayjs"; import useMutation from "hooks/useMutation"; import RegionFields from "components/RegionFields"; @@ -119,7 +118,6 @@ export default function CreateTrip() {
-
); } diff --git a/frontend/pages/forgot-password.tsx b/frontend/pages/forgot-password.tsx deleted file mode 100644 index 84a188a8..00000000 --- a/frontend/pages/forgot-password.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from "react"; -import { useUser } from "providers/user"; -import UtilityPage from "components/UtilityPage"; -import { Link, useNavigate } from "react-router-dom"; -import Input from "components/Input"; -import Button from "components/Button"; -import useMutation from "hooks/useMutation"; -import Alert from "components/Alert"; - -export default function ForgotPassword() { - const navigate = useNavigate(); - const [submitted, setSubmitted] = React.useState(false); - const { loading: userLoading, user } = useUser(); - - if (user?.uid && !userLoading) navigate("/trips"); - - const mutation = useMutation({ - url: "/auth/forgot-password", - method: "POST", - onSuccess: () => { - setSubmitted(true); - }, - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const email = formData.get("email") as string; - mutation.mutate({ email }); - }; - - return ( - - {submitted ? ( -
-

Email Sent

- - If an account exists with this email, you will receive password reset instructions shortly. - - - ← Return to login - -
- ) : ( - <> -

Forgot your password?

-

- Enter your email and we'll send you a link to reset your password. -

- -
- -
- - -
- - ← Back to login - -
- - )} -
- ); -} diff --git a/frontend/pages/import-lifelist.tsx b/frontend/pages/import-lifelist.tsx index 4f7d5666..56a856ff 100644 --- a/frontend/pages/import-lifelist.tsx +++ b/frontend/pages/import-lifelist.tsx @@ -1,13 +1,12 @@ import React from "react"; import toast from "react-hot-toast"; -import { useProfile } from "providers/profile"; +import { useUser } from "hooks/useUser"; import { useSearchParams } from "react-router-dom"; import Header from "components/Header"; import Button from "components/Button"; import Card from "components/Card"; import Footer from "components/Footer"; import Icon from "components/Icon"; -import LoginModal from "components/LoginModal"; import LifelistUpload from "components/LifelistUpload"; import EbirdDownloadLink from "components/EbirdDownloadLink"; import { Link } from "react-router-dom"; @@ -21,7 +20,9 @@ import Alert from "components/Alert"; export default function ImportLifelist() { const [exceptionsValue, setExceptionsValue] = React.useState([]); const [seededKey, setSeededKey] = React.useState(null); - const { lifelist, lifelistUpdatedAt, exceptions } = useProfile(); + const { user, lifelist } = useUser(); + const lifelistUpdatedAt = user?.lifelistUpdatedAt; + const exceptions = user?.exceptions; const queryClient = useQueryClient(); const [searchParams] = useSearchParams(); @@ -40,7 +41,7 @@ export default function ImportLifelist() { url: "/profile", method: "PATCH", onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [`/profile`] }); + queryClient.invalidateQueries({ queryKey: ["/auth/me"] }); }, }); @@ -49,7 +50,7 @@ export default function ImportLifelist() { method: "PUT", onSuccess: () => { toast.success("Life list imported"); - queryClient.invalidateQueries({ queryKey: [`/profile`] }); + queryClient.invalidateQueries({ queryKey: ["/auth/me"] }); }, }); @@ -181,7 +182,6 @@ export default function ImportLifelist() {