diff --git a/apps/journal/app/lib/email.server.test.ts b/apps/journal/app/lib/email.server.test.ts new file mode 100644 index 0000000..0166450 --- /dev/null +++ b/apps/journal/app/lib/email.server.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock nodemailer before importing +vi.mock("nodemailer", () => ({ + createTransport: vi.fn().mockReturnValue({ + sendMail: vi.fn().mockResolvedValue({ messageId: "test-id" }), + }), +})); + +describe("email.server", () => { + const originalEnv = process.env.NODE_ENV; + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + process.env.NODE_ENV = originalEnv; + delete process.env.SMTP_URL; + }); + + it("logs to console in dev mode", async () => { + process.env.NODE_ENV = "development"; + const { sendEmail } = await import("./email.server"); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await sendEmail("test@example.com", "Test Subject", "

Hello

", "Hello"); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("test@example.com"), + ); + consoleSpy.mockRestore(); + }); + + it("does not call SMTP in dev mode", async () => { + process.env.NODE_ENV = "development"; + const nodemailer = await import("nodemailer"); + const { sendEmail } = await import("./email.server"); + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await sendEmail("test@example.com", "Test", "

Hi

", "Hi"); + + expect(nodemailer.createTransport).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it("magicLinkTemplate includes link and expiry note", async () => { + const { magicLinkTemplate } = await import("./email.server"); + const { html, text } = magicLinkTemplate("https://trails.cool/auth/verify?token=abc"); + + expect(html).toContain("https://trails.cool/auth/verify?token=abc"); + expect(html).toContain("15 minutes"); + expect(text).toContain("https://trails.cool/auth/verify?token=abc"); + expect(text).toContain("15 minutes"); + }); + + it("welcomeTemplate includes username", async () => { + const { welcomeTemplate } = await import("./email.server"); + const { html, text } = welcomeTemplate("Alice"); + + expect(html).toContain("Alice"); + expect(text).toContain("Alice"); + expect(html).toContain("trails.cool"); + }); +}); diff --git a/apps/journal/app/lib/email.server.ts b/apps/journal/app/lib/email.server.ts new file mode 100644 index 0000000..079f88a --- /dev/null +++ b/apps/journal/app/lib/email.server.ts @@ -0,0 +1,99 @@ +import { createTransport, type Transporter } from "nodemailer"; + +const FROM = process.env.SMTP_FROM ?? "trails.cool "; + +let transporter: Transporter | null = null; +function getTransporter(): Transporter { + if (!transporter) { + const url = process.env.SMTP_URL; + if (!url) throw new Error("SMTP_URL is not set"); + transporter = createTransport(url); + } + return transporter; +} + +export async function sendEmail( + to: string, + subject: string, + html: string, + text: string, +): Promise { + if (process.env.NODE_ENV !== "production") { + console.log(`[Email] To: ${to} | Subject: ${subject}`); + console.log(`[Email] Text:\n${text}`); + return; + } + + await getTransporter().sendMail({ from: FROM, to, subject, html, text }); +} + +// --- Templates --- + +export function magicLinkTemplate(link: string): { html: string; text: string } { + const html = ` +
+

Sign in to trails.cool

+

Click the button below to sign in to your account. This link expires in 15 minutes.

+ Sign In +

If the button doesn't work, copy and paste this link:
${link}

+
+

If you didn't request this link, you can safely ignore this email.

+
+ `.trim(); + + const text = [ + "Sign in to trails.cool", + "", + `Click here to sign in: ${link}`, + "", + "This link expires in 15 minutes.", + "", + "If you didn't request this link, you can safely ignore this email.", + ].join("\n"); + + return { html, text }; +} + +export function welcomeTemplate(username: string): { html: string; text: string } { + const html = ` +
+

Welcome to trails.cool, ${username}!

+

Your account is ready. Here's what you can do:

+
    +
  • Save and manage your routes
  • +
  • Track outdoor activities
  • +
  • Plan routes collaboratively in the Planner
  • +
  • Export routes as GPX for any GPS device
  • +
+ View Your Routes +
+

trails.cool — Your outdoor activity journal

+
+ `.trim(); + + const text = [ + `Welcome to trails.cool, ${username}!`, + "", + "Your account is ready. Here's what you can do:", + "- Save and manage your routes", + "- Track outdoor activities", + "- Plan routes collaboratively in the Planner", + "- Export routes as GPX for any GPS device", + "", + "View your routes: https://trails.cool/routes", + ].join("\n"); + + return { html, text }; +} + +// --- Convenience wrappers --- + +export async function sendMagicLink(email: string, link: string): Promise { + const { html, text } = magicLinkTemplate(link); + await sendEmail(email, "Sign in to trails.cool", html, text); +} + +export async function sendWelcome(email: string, username: string): Promise { + const { html, text } = welcomeTemplate(username); + await sendEmail(email, "Welcome to trails.cool!", html, text); +} diff --git a/apps/journal/app/routes/api.auth.login.ts b/apps/journal/app/routes/api.auth.login.ts index e5eacc6..e4f766e 100644 --- a/apps/journal/app/routes/api.auth.login.ts +++ b/apps/journal/app/routes/api.auth.login.ts @@ -1,6 +1,7 @@ import { data } from "react-router"; import type { Route } from "./+types/api.auth.login"; import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server"; +import { sendMagicLink } from "~/lib/email.server"; export async function action({ request }: Route.ActionArgs) { const body = await request.json(); @@ -22,12 +23,13 @@ export async function action({ request }: Route.ActionArgs) { const token = await createMagicToken(email); const origin = process.env.ORIGIN ?? "http://localhost:3000"; const link = `${origin}/auth/verify?token=${token}`; - console.log(`[Magic Link] ${email}: ${link}`); - // In dev, return the link directly so the client can auto-redirect if (process.env.NODE_ENV !== "production") { + console.log(`[Magic Link] ${email}: ${link}`); return data({ step: "magic-link-sent", devLink: link }); } + + await sendMagicLink(email, link); return data({ step: "magic-link-sent" }); } diff --git a/apps/journal/app/routes/api.auth.register.ts b/apps/journal/app/routes/api.auth.register.ts index c791676..0efe1a6 100644 --- a/apps/journal/app/routes/api.auth.register.ts +++ b/apps/journal/app/routes/api.auth.register.ts @@ -1,6 +1,7 @@ import { data } from "react-router"; import type { Route } from "./+types/api.auth.register"; import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server"; +import { sendWelcome } from "~/lib/email.server"; export async function action({ request }: Route.ActionArgs) { const body = await request.json(); @@ -15,6 +16,10 @@ export async function action({ request }: Route.ActionArgs) { if (step === "finish") { const newUserId = await finishRegistration(userId, email, username, response, challenge); const cookie = await createSession(newUserId, request); + // Send welcome email (fire-and-forget — don't block registration on email) + sendWelcome(email, username).catch((err) => + console.error("[Email] Failed to send welcome email:", err), + ); return data({ step: "done" }, { headers: { "Set-Cookie": cookie } }); } diff --git a/apps/journal/app/routes/privacy.tsx b/apps/journal/app/routes/privacy.tsx index 012ad02..ef38871 100644 --- a/apps/journal/app/routes/privacy.tsx +++ b/apps/journal/app/routes/privacy.tsx @@ -70,12 +70,30 @@ export default function PrivacyPage() {

+
+

Email

+

+ The Journal sends transactional emails for magic link login and welcome messages + via SMTP. On the official instance, emails are sent through our own mail server. +

+

What is sent via email:

+
    +
  • Magic link: a one-time login link sent to your email address
  • +
  • Welcome email: a greeting after registration
  • +
+

+ Self-hosted instances can configure their own SMTP server. No email content + is stored beyond what your mail server retains. +

+
+

Third Parties

  • Sentry (Functional Software Inc.) — error tracking, as described above
  • OpenStreetMap — map tiles are loaded from OSM tile servers. OSM's privacy policy applies to tile requests.
  • BRouter — routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.
  • +
  • SMTP provider — transactional emails (magic link, welcome) are delivered via SMTP. Self-hosters configure their own provider.
diff --git a/apps/journal/package.json b/apps/journal/package.json index 1b946ab..0ee0707 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -26,6 +26,7 @@ "drizzle-orm": "catalog:", "isbot": "^5.1.0", "jose": "^6.2.2", + "nodemailer": "^8.0.4", "react": "catalog:", "react-dom": "catalog:", "react-router": "catalog:" @@ -34,6 +35,7 @@ "@react-router/dev": "catalog:", "@simplewebauthn/types": "^12.0.0", "@tailwindcss/vite": "catalog:", + "@types/nodemailer": "^7.0.11", "@types/react": "catalog:", "@types/react-dom": "catalog:", "tailwindcss": "catalog:", diff --git a/apps/planner/app/components/NoGoAreaLayer.tsx b/apps/planner/app/components/NoGoAreaLayer.tsx new file mode 100644 index 0000000..2111e7d --- /dev/null +++ b/apps/planner/app/components/NoGoAreaLayer.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef, useCallback } from "react"; +import { useMap } from "react-leaflet"; +import L from "leaflet"; +import * as Y from "yjs"; +import "@geoman-io/leaflet-geoman-free"; +import "@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css"; + +interface NoGoAreaLayerProps { + noGoAreas: Y.Array>; + doc: Y.Doc; + enabled: boolean; + onToggle: () => void; +} + +const NO_GO_STYLE: L.PathOptions = { + color: "#ef4444", + fillColor: "#ef4444", + fillOpacity: 0.2, + weight: 2, +}; + +function yMapToLatLngs(yMap: Y.Map): L.LatLng[] { + const points = yMap.get("points") as Array<{ lat: number; lon: number }> | undefined; + if (!points) return []; + return points.map((p) => L.latLng(p.lat, p.lon)); +} + +export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLayerProps) { + const map = useMap(); + const layerGroupRef = useRef(L.layerGroup()); + const polygonMapRef = useRef>(new Map()); + const suppressObserverRef = useRef(false); + + // Sync Yjs → Leaflet layers + const syncLayers = useCallback(() => { + const lg = layerGroupRef.current; + // Clear all existing polygons + lg.clearLayers(); + polygonMapRef.current.clear(); + + for (let i = 0; i < noGoAreas.length; i++) { + const yMap = noGoAreas.get(i); + const latLngs = yMapToLatLngs(yMap); + if (latLngs.length < 3) continue; + + const polygon = L.polygon(latLngs, NO_GO_STYLE); + polygon.on("contextmenu", (e) => { + L.DomEvent.preventDefault(e as unknown as Event); + suppressObserverRef.current = true; + noGoAreas.delete(i, 1); + suppressObserverRef.current = false; + syncLayers(); + }); + lg.addLayer(polygon); + polygonMapRef.current.set(i, polygon); + } + }, [noGoAreas]); + + // Add/remove layer group from map + useEffect(() => { + const lg = layerGroupRef.current; + lg.addTo(map); + return () => { + lg.removeFrom(map); + }; + }, [map]); + + // Observe Yjs changes + useEffect(() => { + const observer = () => { + if (!suppressObserverRef.current) { + syncLayers(); + } + }; + noGoAreas.observeDeep(observer); + syncLayers(); + return () => noGoAreas.unobserveDeep(observer); + }, [noGoAreas, syncLayers]); + + // Geoman drawing controls + useEffect(() => { + if (enabled) { + map.pm.enableDraw("Polygon", { + pathOptions: NO_GO_STYLE, + snappable: false, + }); + } else { + map.pm.disableDraw(); + } + + const handleCreate = (e: { layer: L.Layer }) => { + const layer = e.layer as L.Polygon; + const latLngs = (layer.getLatLngs()[0] as L.LatLng[]); + const points = latLngs.map((ll) => ({ lat: ll.lat, lon: ll.lng })); + + // Remove the geoman-created layer (we'll render from Yjs) + map.removeLayer(layer); + + // Add to Yjs + const yMap = new Y.Map(); + yMap.set("points", points); + doc.transact(() => { + noGoAreas.push([yMap]); + }); + + // Exit draw mode + onToggle(); + }; + + map.on("pm:create", handleCreate); + + return () => { + map.pm.disableDraw(); + map.off("pm:create", handleCreate); + }; + }, [map, enabled, noGoAreas, doc, onToggle]); + + return null; +} diff --git a/apps/planner/app/components/NotesPanel.tsx b/apps/planner/app/components/NotesPanel.tsx new file mode 100644 index 0000000..2c870bf --- /dev/null +++ b/apps/planner/app/components/NotesPanel.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef, useCallback } from "react"; +import type { YjsState } from "~/lib/use-yjs"; + +interface NotesPanelProps { + yjs: YjsState; +} + +export function NotesPanel({ yjs }: NotesPanelProps) { + const textareaRef = useRef(null); + const isLocalChange = useRef(false); + + // Sync Y.Text → textarea + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + // Set initial value + textarea.value = yjs.notes.toString(); + + const observer = () => { + if (isLocalChange.current) return; + const pos = textarea.selectionStart; + textarea.value = yjs.notes.toString(); + textarea.selectionStart = pos; + textarea.selectionEnd = pos; + }; + + yjs.notes.observe(observer); + return () => yjs.notes.unobserve(observer); + }, [yjs.notes]); + + // textarea → Y.Text + const handleInput = useCallback( + (e: React.FormEvent) => { + const textarea = e.currentTarget; + const newValue = textarea.value; + const currentValue = yjs.notes.toString(); + + if (newValue === currentValue) return; + + isLocalChange.current = true; + yjs.doc.transact(() => { + yjs.notes.delete(0, yjs.notes.length); + yjs.notes.insert(0, newValue); + }); + isLocalChange.current = false; + }, + [yjs.notes, yjs.doc], + ); + + return ( +
+
+

Notes

+
+
+