From 3c2c9f0dbaf8e3b64e12a8cff1047ca0f16dd6b3 Mon Sep 17 00:00:00 2001
From: Joaquim d'Souza
Date: Wed, 5 Aug 2026 15:46:11 +0200
Subject: [PATCH 1/6] feat: read-only shared map viewer with password gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Stages 4-5 of the read-only private maps feature (plan in
READ_ONLY_PRIVATE_MAPS.md):
- /share/[token] page: resolves the share, mints the grant cookie via
a claim route handler for passwordless shares (cookies cannot be set
during page render), renders the password form for protected ones,
and seeds the map query cache server-side
- Password gate: SharePasswordForm + POST /api/share/[token]/verify
with scrypt verification and per-IP+token Redis rate limiting
- Read-only shell: ReadOnlyNavbar (name, view switcher, area search —
no thumbnail upload or initial-view writes), ReadOnlyMapControls
(boundary hover info, inspector, style/zoom/timeline), LegendDisplay
(display-only choropleth legend) + existing MarkerLegend
- isReadOnlyRouteAtom + useMapEditable(); inspector hides its config
gears, "Add to areas" and "View in table" in read-only mode
- useMapViews skips server writes on the read-only route so map style
and timeline changes stay client-side only
- useDataSources uses listForMapView for anonymous viewer routes
- Markers API honours the properties param for grant holders whose
share covers the data source, so marker styling matches the editor
Co-Authored-By: Claude Fable 5
---
.../(private)/map/[id]/atoms/mapStateAtoms.ts | 8 ++
.../ConfigurableDataRecordsPanel.tsx | 4 +-
.../InspectorPanel/InspectorDataTab.tsx | 42 ++++---
.../InspectorPanel/InspectorPanel.tsx | 25 ++--
.../[id]/components/Legend/LegendDisplay.tsx | 77 ++++++++++++
.../readonly/ReadOnlyMapControls.tsx | 56 +++++++++
.../components/readonly/ReadOnlyMapViews.tsx | 47 ++++++++
.../components/readonly/ReadOnlyNavbar.tsx | 40 +++++++
.../map/[id]/hooks/useMapEditable.ts | 18 +++
.../(private)/map/[id]/hooks/useMapViews.ts | 22 +++-
.../map/[id]/providers/MapJotaiProvider.tsx | 10 ++
.../api/data-sources/[id]/markers/route.ts | 37 ++++--
src/app/api/share/[token]/claim/route.ts | 33 ++++++
src/app/api/share/[token]/verify/route.ts | 61 ++++++++++
.../[token]/components/SharePasswordForm.tsx | 85 ++++++++++++++
src/app/share/[token]/page.tsx | 111 ++++++++++++++++++
src/hooks/useDataSources.ts | 17 ++-
src/server/services/ratelimit.ts | 10 ++
18 files changed, 655 insertions(+), 48 deletions(-)
create mode 100644 src/app/(private)/map/[id]/components/Legend/LegendDisplay.tsx
create mode 100644 src/app/(private)/map/[id]/components/readonly/ReadOnlyMapControls.tsx
create mode 100644 src/app/(private)/map/[id]/components/readonly/ReadOnlyMapViews.tsx
create mode 100644 src/app/(private)/map/[id]/components/readonly/ReadOnlyNavbar.tsx
create mode 100644 src/app/(private)/map/[id]/hooks/useMapEditable.ts
create mode 100644 src/app/api/share/[token]/claim/route.ts
create mode 100644 src/app/api/share/[token]/verify/route.ts
create mode 100644 src/app/share/[token]/components/SharePasswordForm.tsx
create mode 100644 src/app/share/[token]/page.tsx
diff --git a/src/app/(private)/map/[id]/atoms/mapStateAtoms.ts b/src/app/(private)/map/[id]/atoms/mapStateAtoms.ts
index c5d6090d0..9372840fa 100644
--- a/src/app/(private)/map/[id]/atoms/mapStateAtoms.ts
+++ b/src/app/(private)/map/[id]/atoms/mapStateAtoms.ts
@@ -13,6 +13,14 @@ export const mapModeAtom = atom("private");
*/
export const isPublicMapRouteAtom = atom(false);
+/**
+ * `true` on the read-only shared map page (`/share/[token]`), `false`
+ * everywhere else. Components use `useMapEditable()` to hide editing
+ * affordances, and `useMapViews` skips server writes so view-config
+ * changes (map style, timeline) stay client-side only.
+ */
+export const isReadOnlyRouteAtom = atom(false);
+
/** Derived: the navbar is visible exactly when we are NOT on the public route. */
export const showNavbarAtom = atom(
(get) => !get(isPublicMapRouteAtom),
diff --git a/src/app/(private)/map/[id]/components/InspectorPanel/ConfigurableDataRecordsPanel.tsx b/src/app/(private)/map/[id]/components/InspectorPanel/ConfigurableDataRecordsPanel.tsx
index be9da4591..da095dbc1 100644
--- a/src/app/(private)/map/[id]/components/InspectorPanel/ConfigurableDataRecordsPanel.tsx
+++ b/src/app/(private)/map/[id]/components/InspectorPanel/ConfigurableDataRecordsPanel.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useMapEditable } from "../../hooks/useMapEditable";
import { useOpenInspectorConfig } from "../../hooks/useOpenInspectorConfig";
import DataRecordsPanel from "./DataRecordsPanel";
import { InspectorConfigModal } from "./InspectorConfigModal";
@@ -20,6 +21,7 @@ export default function ConfigurableDataRecordsPanel({
}) {
const { config, isModalOpen, setIsModalOpen, openConfig, onUpdateConfig } =
useOpenInspectorConfig(dataSourceId);
+ const editable = useMapEditable();
return (
<>
@@ -29,7 +31,7 @@ export default function ConfigurableDataRecordsPanel({
isLoading={isLoading}
defaultExpanded={defaultExpanded}
hint={hint}
- onClickConfigure={openConfig}
+ onClickConfigure={editable ? openConfig : undefined}
/>
{config && (
{dataSource.name}
-
+ {editable && (
+
+ )}
)}
@@ -311,15 +315,17 @@ export default function InspectorDataTab() {
defaultExpanded={index === 0}
/>
))}
-
+ {editable && (
+
+ )}