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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ VITE_SUPABASE_URL=https://<project>.supabase.co
VITE_SUPABASE_ANON_KEY=<anon-key>
```

Sign-in supports **Google OAuth** and an email magic-link fallback. Boards
created while signed in are private to your account by default and can be shared
for realtime collaboration with one click. Full setup — including the Google
OAuth client and Supabase configuration — is documented in
[`docs/SETUP.md`](docs/SETUP.md).

## Deploying

Pushing to `main` runs the GitHub Pages workflow. The repo's two secrets must be set:
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/features/auth/useAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useState } from "react";
import type { Session } from "@supabase/supabase-js";
import { getSupabase } from "../../lib/supabase";

type Status = "idle" | "sending" | "sent" | "error";

const NOT_CONFIGURED =
"Supabase is not configured. Add VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY.";

function callbackUrl(): string {
return `${window.location.origin}${import.meta.env.BASE_URL}auth/callback`;
}

// Single source of truth for auth: tracks the session and exposes Google OAuth
// and email magic-link sign-in. Both providers funnel through Supabase, which
// persists the session to localStorage and refreshes tokens automatically;
// when Supabase is unconfigured every action degrades gracefully so the board
// keeps working in local-only mode.
export function useAuth() {
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const [session, setSession] = useState<Session | null>(null);

useEffect(() => {
const supa = getSupabase();
if (!supa) return;
void supa.auth.getSession().then(({ data }) => setSession(data.session));
const { data: sub } = supa.auth.onAuthStateChange((_event, s) => setSession(s));
return () => sub.subscription.unsubscribe();
}, []);

const signInWithGoogle = useCallback(async () => {
const supa = getSupabase();
if (!supa) {
setStatus("error");
setError(NOT_CONFIGURED);
return;
}
setError(null);
const { error: err } = await supa.auth.signInWithOAuth({
provider: "google",
options: { redirectTo: callbackUrl() },
});
if (err) {
setStatus("error");
setError(err.message);
}
// On success the browser is redirected to Google; no further state needed.
}, []);

const sendMagicLink = useCallback(async (email: string) => {
const supa = getSupabase();
if (!supa) {
setStatus("error");
setError(NOT_CONFIGURED);
return;
}
setStatus("sending");
setError(null);
const { error: err } = await supa.auth.signInWithOtp({
email,
options: { emailRedirectTo: callbackUrl() },
});
if (err) {
setStatus("error");
setError(err.message);
} else {
setStatus("sent");
}
}, []);

const signOut = useCallback(async () => {
const supa = getSupabase();
if (!supa) return;
await supa.auth.signOut();
}, []);

return { status, error, session, signInWithGoogle, sendMagicLink, signOut };
}
48 changes: 0 additions & 48 deletions apps/web/src/features/auth/useMagicLink.ts

This file was deleted.

45 changes: 45 additions & 0 deletions apps/web/src/features/board/BoardAccessIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useState } from "react";
import type { SupabaseClient } from "@supabase/supabase-js";
import { setBoardVisibility } from "./boardOwnership";

interface Props {
client: SupabaseClient | null;
boardId: string;
owned: boolean;
isPublic: boolean;
}

// Compact ownership/sharing chip shown on a board. Only the owner can flip a
// board between private (annotations visible only to them) and shared (anyone
// with the link can collaborate in realtime). Hidden entirely in local-only or
// signed-out contexts, where there is no ownership to express.
export function BoardAccessIndicator({ client, boardId, owned, isPublic }: Props) {
const [pub, setPub] = useState(isPublic);
const [busy, setBusy] = useState(false);

if (!client || !owned) return null;

async function toggle() {
setBusy(true);
const next = !pub;
const res = await setBoardVisibility(client, boardId, next);
if (res.ok) setPub(next);
setBusy(false);
}

return (
<div className="board-access">
<span className="board-access__label">
{pub ? "🔗 Shared" : "🔒 Private"}
</span>
<button
className="board-access__toggle"
onClick={() => void toggle()}
disabled={busy}
title={pub ? "Make private (owner only)" : "Share — anyone with the link can edit"}
>
{pub ? "Make private" : "Share"}
</button>
</div>
);
}
62 changes: 46 additions & 16 deletions apps/web/src/features/board/boardOwnership.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,63 @@
import type { SupabaseClient } from "@supabase/supabase-js";

export interface BoardAccess {
owned: boolean;
isPublic: boolean;
}

// Ensure a `boards` row exists for this id and claim ownership when it is
// unowned and we are signed in. Idempotent. Returns whether the current user
// owns the board, which gates the named-snapshot Save/Restore UI (owner-only per
// the RLS policy in 0001_init.sql).
// owns the board (which gates the named-snapshot Save/Restore UI, owner-only
// per the RLS in 0001) and whether the board is public.
//
// Privacy model: a board a signed-in user creates is PRIVATE by default
// (is_public = false) so their annotations are visible only to them until they
// explicitly share it (see setBoardVisibility). Guests and pre-existing boards
// keep the legacy public/free-for-all behavior.
//
// Safe to call with a null client (local-only mode) or signed out — both yield
// { owned: false } without throwing, so PDF export and undo still work.
// { owned: false, isPublic: true } without throwing, so PDF export and undo
// still work.
export async function ensureBoardOwnership(
client: SupabaseClient | null,
boardId: string,
): Promise<{ owned: boolean }> {
if (!client) return { owned: false };
): Promise<BoardAccess> {
if (!client) return { owned: false, isPublic: true };

const { data: auth } = await client.auth.getUser();
const userId = auth.user?.id ?? null;

// SELECT is allowed for any public board.
const { data: existing } = await client
.from("boards")
.select("id, owner_id")
.select("id, owner_id, is_public")
.eq("id", boardId)
.maybeSingle();

if (!userId) return { owned: false };
if (!userId) return { owned: false, isPublic: existing?.is_public ?? true };

if (!existing) {
// boards_insert_any allows check(true); claim ownership on first load.
// Claim ownership on first load; private by default for signed-in creators.
const { error } = await client
.from("boards")
.insert({ id: boardId, owner_id: userId });
.insert({ id: boardId, owner_id: userId, is_public: false });
if (error) {
// A racing peer may have inserted first → re-read to settle ownership.
const { data: row } = await client
.from("boards")
.select("owner_id")
.select("owner_id, is_public")
.eq("id", boardId)
.maybeSingle();
return { owned: row?.owner_id === userId };
return {
owned: row?.owner_id === userId,
isPublic: row?.is_public ?? true,
};
}
return { owned: true };
return { owned: true, isPublic: false };
}

if (existing.owner_id === userId) return { owned: true };
if (existing.owner_id === userId) {
return { owned: true, isPublic: existing.is_public };
}

if (existing.owner_id === null) {
// boards_update_owner allows update while owner_id is null.
Expand All @@ -51,8 +66,23 @@ export async function ensureBoardOwnership(
.update({ owner_id: userId })
.eq("id", boardId)
.is("owner_id", null);
return { owned: !error };
return { owned: !error, isPublic: existing.is_public };
}

return { owned: false }; // owned by someone else
return { owned: false, isPublic: existing.is_public }; // owned by someone else
}

// Toggle a board between private (owner-only) and public (collaborative,
// anyone with the link). Owner-only per the boards_update_owner RLS policy.
export async function setBoardVisibility(
client: SupabaseClient | null,
boardId: string,
isPublic: boolean,
): Promise<{ ok: boolean; error?: string }> {
if (!client) return { ok: false, error: "Not connected." };
const { error } = await client
.from("boards")
.update({ is_public: isPublic })
.eq("id", boardId);
return error ? { ok: false, error: error.message } : { ok: true };
}
34 changes: 31 additions & 3 deletions apps/web/src/features/canvas/SaveStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { useShapeStore } from "@notux/canvas";

function formatRelative(date: Date): string {
Expand All @@ -9,12 +10,35 @@ function formatRelative(date: Date): string {
return `${Math.floor(mins / 60)}h ago`;
}

// Tracks browser connectivity so the board can tell the user that, while
// offline, edits are still being persisted locally (IndexedDB) and will sync
// when the connection returns.
function useOnline(): boolean {
const [online, setOnline] = useState(
typeof navigator === "undefined" ? true : navigator.onLine,
);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", off);
};
}, []);
return online;
}

export function SaveStatus() {
const synced = useShapeStore((s) => s.synced);
const lastSaved = useShapeStore((s) => s.lastSaved);
const online = useOnline();

if (!synced) return null;

const offline = !online;

return (
<div
aria-live="polite"
Expand All @@ -24,16 +48,20 @@ export function SaveStatus() {
right: 16,
padding: "4px 10px",
borderRadius: 8,
background: "rgba(30,30,30,0.75)",
background: offline ? "rgba(120,80,20,0.85)" : "rgba(30,30,30,0.75)",
backdropFilter: "blur(8px)",
color: "rgba(255,255,255,0.6)",
color: offline ? "rgba(255,220,160,0.95)" : "rgba(255,255,255,0.6)",
fontSize: 12,
pointerEvents: "none",
userSelect: "none",
zIndex: 20,
}}
>
{lastSaved ? `Saved ${formatRelative(lastSaved)}` : "Saved"}
{offline
? "Offline — saved locally, will sync when reconnected"
: lastSaved
? `Saved ${formatRelative(lastSaved)}`
: "Saved"}
</div>
);
}
Loading
Loading