Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

A collaborative, real-time trip map and journal. Anyone can explore a trip with
no account — filter days, browse route segments, and open notes and photos
pinned to the map. Signed-in friends auto-join as members to drop their own
notes and geotagged photos, and edits stream to everyone live.
pinned to the map. Invited friends can sign in to drop their own notes and
geotagged photos, and edits stream to everyone live.

<p>
<a href="https://lofoten-map-kappa.vercel.app"><strong>▶ Live demo</strong></a>
Expand Down Expand Up @@ -39,7 +39,7 @@ The interesting problems this project solves:
- **Real authorization, not a toy.** Postgres row-level security enforces
*public reads, member contributions, and owner-or-admin writes* at the
database — the client can't bypass it. Members self-serve admin requests that
existing admins approve in-app.
existing admins approve in-app. Signing in alone never grants edit access.
- **Live collaboration.** Supabase Realtime streams inserts/updates for photos,
notes, places, and routes, so a second browser sees changes appear instantly.
- **Zero-config demo mode.** With no backend keys set, the app boots from
Expand Down Expand Up @@ -177,9 +177,10 @@ Keep the Supabase variables empty to stay in demo mode. To enable shared mode:
5. Edit `supabase/grant-member.sql` with your email and run it to make your
first account an admin.
6. Reload — you should see the seeded trip and admin controls.
7. Friends can view the trip without signing in. When they sign in, the app
auto-joins them as members so they can contribute notes/photos and request
admin access in-app.
7. Friends can view the trip without signing in. To contribute, a friend first
signs in once so Supabase creates their Auth account; an admin then adds that
email from the in-app Members panel. Their next reload enables notes/photos
and admin-access requests.

### What the schema sets up

Expand All @@ -190,6 +191,9 @@ collaborative tables, and RLS policies:

- **Reads are public** — `select` is granted to `anon` with `using (true)`, so
anyone can view the trip without an account.
- **Contribution is invite-only** — signing in does not create a membership.
An existing admin must add the account email from the Members panel after the
person has signed in once (or use `grant-member.sql` for initial setup).
- **Notes and photos** can be created by any signed-in member; each row is
updatable and deletable by its owner or a trip admin.
- **Trips, days, routes, places, and membership** are admin-scoped.
Expand Down Expand Up @@ -274,7 +278,9 @@ automatically, and CI validates a production build on every PR.
- Signed-out visitors see the seeded trip (reads are public); the "Sign in"
button opens the optional sign-in panel.
- Your admin account shows contribute/admin controls; a guest does not.
- A newly signed-in friend can contribute notes/photos and request admin.
- A newly signed-in, uninvited account remains view-only.
- After an admin adds that account's email in Members and it reloads, the
friend can contribute notes/photos and request admin access.
- A note saves and survives reload.
- A small photo uploads, renders on the map, and survives reload.
- An admin can approve/deny admin requests and adjust member roles.
Expand Down
2 changes: 1 addition & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,7 @@ export default function Home() {
<Sparkles className="h-3.5 w-3.5 shrink-0 text-[#d0872f]" /> <span className="truncate">{tripTitle}</span>
</div>
<div className="flex items-center gap-2">
<div className="pointer-events-auto hidden rounded-full border border-stone-200/80 bg-[rgba(255,253,246,0.9)] px-4 py-2 text-xs font-semibold text-stone-700 shadow-lg backdrop-blur sm:block">{supabase ? (user ? `Signed in ${user.email ?? ""}` : "Viewing as guest") : "Local demo mode"}</div>
<div className="pointer-events-auto hidden rounded-full border border-stone-200/80 bg-[rgba(255,253,246,0.9)] px-4 py-2 text-xs font-semibold text-stone-700 shadow-lg backdrop-blur sm:block">{supabase ? (user ? (currentMember ? `Signed in ${user.email ?? ""}` : "Signed in · view only") : "Viewing as guest") : "Local demo mode"}</div>
<button
onClick={startJourney}
disabled={journeyItems.length === 0}
Expand Down
15 changes: 15 additions & 0 deletions lib/access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ describe("deriveTripAccess", () => {
expect(access.showAdminRequestControls).toBe(false);
});

it("keeps a signed-in but uninvited visitor in view-only mode", () => {
const access = deriveTripAccess({
supabaseEnabled: true,
userId: "visitor-1",
members: [member({ user_id: "friend-1" })],
adminRequests: [],
});
expect(access.currentUserId).toBe("visitor-1");
expect(access.currentMember).toBeNull();
expect(access.canContribute).toBe(false);
expect(access.isAdmin).toBe(false);
expect(access.showMemberAdminControls).toBe(false);
expect(access.showAdminRequestControls).toBe(false);
});

it("lets signed-in members contribute and request admin access", () => {
const current = member({ user_id: "user-1", role: "member" });
const access = deriveTripAccess({ supabaseEnabled: true, userId: "user-1", members: [current], adminRequests: [] });
Expand Down
1 change: 0 additions & 1 deletion lib/hooks/useTripData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export function useTripData({ supabase, user, authLoading, tripSlug, initialData
: "The trip is not set up yet. Ask an admin to finish creating it, then refresh.");
return;
}
if (user) await supabase.rpc("ensure_trip_membership", { target_trip_slug: tripSlug });
const adminRequestsQuery = user
? supabase.from("admin_requests").select("*").eq("trip_id", trip.id).order("created_at", { ascending: false })
: Promise.resolve({ data: [], error: null });
Expand Down
30 changes: 30 additions & 0 deletions lib/invite-only-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

const root = process.cwd();
const read = (path: string) => readFileSync(join(root, path), "utf8");

describe("invite-only contribution contract", () => {
it("does not auto-enroll signed-in visitors from the data-loading client", () => {
const source = read("lib/hooks/useTripData.ts");
expect(source).not.toContain('rpc("ensure_trip_membership"');
});

it("removes the legacy self-enrollment RPC from fresh and upgraded databases", () => {
const schema = read("supabase/schema.sql");
const migration = read("supabase/migrations/20260711120000_invite_only_contributions.sql");

expect(schema).toContain("drop function if exists public.ensure_trip_membership(text)");
expect(schema).not.toContain("create or replace function public.ensure_trip_membership");
expect(schema).not.toContain("grant execute on function public.ensure_trip_membership");
expect(migration).toContain("drop function if exists public.ensure_trip_membership(text)");
});

it("retains public reads and the admin invitation RPC", () => {
const schema = read("supabase/schema.sql");
expect(schema).toContain('create policy "public read trips" on trips for select to anon, authenticated using (true)');
expect(schema).toContain("create or replace function public.grant_trip_member_by_email");
expect(schema).toContain("grant execute on function public.grant_trip_member_by_email(text, text, text) to authenticated");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Keep trip reads public while closing the legacy path that allowed every
-- signed-in visitor to make themselves a contributing member. Existing rows in
-- trip_members are deliberately untouched, and admins can continue inviting
-- people through grant_trip_member_by_email.
drop function if exists public.ensure_trip_membership(text);
37 changes: 4 additions & 33 deletions supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -274,39 +274,10 @@ $$;

grant execute on function public.grant_trip_member_by_email(text, text, text) to authenticated;

-- Self-service join: a signed-in user adds themselves to the trip as a plain
-- member if they are not already on it. Idempotent, so the client can call it on
-- every sign-in without worrying about duplicates.
create or replace function public.ensure_trip_membership(target_trip_slug text)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
found_trip_id uuid;
found_display_name text;
begin
if auth.uid() is null then
raise exception 'Must be signed in' using errcode = '42501';
end if;

select id into found_trip_id from public.trips where slug = target_trip_slug;
if found_trip_id is null then
raise exception 'Trip not found';
end if;

select coalesce(raw_user_meta_data ->> 'full_name', email)
into found_display_name
from auth.users where id = auth.uid();

insert into public.trip_members (trip_id, user_id, role, display_name)
values (found_trip_id, auth.uid(), 'member', found_display_name)
on conflict (trip_id, user_id) do nothing;
end;
$$;

grant execute on function public.ensure_trip_membership(text) to authenticated;
-- Contributions are invite-only. Remove the legacy self-enrollment RPC when
-- this schema is re-applied to an older project; admins add members through
-- grant_trip_member_by_email instead.
drop function if exists public.ensure_trip_membership(text);

-- Self-service profile edit. trip_members is otherwise admin-write-only, so
-- members reach their own row through this security-definer RPC.
Expand Down
Loading