diff --git a/README.md b/README.md index 26a63a3..bf32bf3 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ VITE_SUPABASE_URL=https://.supabase.co VITE_SUPABASE_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: diff --git a/apps/web/src/features/auth/useAuth.ts b/apps/web/src/features/auth/useAuth.ts new file mode 100644 index 0000000..f9e7f01 --- /dev/null +++ b/apps/web/src/features/auth/useAuth.ts @@ -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("idle"); + const [error, setError] = useState(null); + const [session, setSession] = useState(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 }; +} diff --git a/apps/web/src/features/auth/useMagicLink.ts b/apps/web/src/features/auth/useMagicLink.ts deleted file mode 100644 index c0b4f20..0000000 --- a/apps/web/src/features/auth/useMagicLink.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import type { Session } from "@supabase/supabase-js"; -import { getSupabase } from "../../lib/supabase"; - -type Status = "idle" | "sending" | "sent" | "error"; - -export function useMagicLink() { - const [status, setStatus] = useState("idle"); - const [error, setError] = useState(null); - const [session, setSession] = useState(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 sendMagicLink = useCallback(async (email: string) => { - const supa = getSupabase(); - if (!supa) { - setStatus("error"); - setError("Supabase is not configured. Add VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY."); - return; - } - setStatus("sending"); - setError(null); - const { error: err } = await supa.auth.signInWithOtp({ - email, - options: { emailRedirectTo: `${window.location.origin}${import.meta.env.BASE_URL}auth/callback` }, - }); - 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, sendMagicLink, signOut }; -} diff --git a/apps/web/src/features/board/BoardAccessIndicator.tsx b/apps/web/src/features/board/BoardAccessIndicator.tsx new file mode 100644 index 0000000..b071ee5 --- /dev/null +++ b/apps/web/src/features/board/BoardAccessIndicator.tsx @@ -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 ( +
+ + {pub ? "πŸ”— Shared" : "πŸ”’ Private"} + + +
+ ); +} diff --git a/apps/web/src/features/board/boardOwnership.ts b/apps/web/src/features/board/boardOwnership.ts index 197db98..3284091 100644 --- a/apps/web/src/features/board/boardOwnership.ts +++ b/apps/web/src/features/board/boardOwnership.ts @@ -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 { + 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. @@ -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 }; } diff --git a/apps/web/src/features/canvas/SaveStatus.tsx b/apps/web/src/features/canvas/SaveStatus.tsx index d0b2052..0df7338 100644 --- a/apps/web/src/features/canvas/SaveStatus.tsx +++ b/apps/web/src/features/canvas/SaveStatus.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { useShapeStore } from "@notux/canvas"; function formatRelative(date: Date): string { @@ -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 (
- {lastSaved ? `Saved ${formatRelative(lastSaved)}` : "Saved"} + {offline + ? "Offline β€” saved locally, will sync when reconnected" + : lastSaved + ? `Saved ${formatRelative(lastSaved)}` + : "Saved"}
); } diff --git a/apps/web/src/features/canvas/useIdentity.ts b/apps/web/src/features/canvas/useIdentity.ts index ad0a759..4ec7328 100644 --- a/apps/web/src/features/canvas/useIdentity.ts +++ b/apps/web/src/features/canvas/useIdentity.ts @@ -1,10 +1,11 @@ import { useMemo } from "react"; import { colorForSeed } from "@notux/sync"; -import { useMagicLink } from "../auth/useMagicLink"; +import { useAuth } from "../auth/useAuth"; export interface Identity { name: string; color: string; + avatarUrl: string | null; } // A stable per-tab id for anonymous guests, so a signed-out user keeps one @@ -19,15 +20,27 @@ function guestSeed(): string { return id; } -// Presence identity: the signed-in user's email local-part, or "Guest". The -// color is deterministic from a stable seed so the same person is the same -// color across all clients. +// Presence identity: a signed-in user's display name (Google full name, else +// email local-part), or "Guest". The color is deterministic from a stable seed +// so the same person is the same color across all clients. Avatar, when present +// (Google), rides along for collaborator cursors. This is derived entirely from +// the user's own session β€” never from another user's profile row β€” so account +// info stays private to its owner. export function useIdentity(): Identity { - const { session } = useMagicLink(); + const { session } = useAuth(); return useMemo(() => { - const email = session?.user?.email ?? null; - const name = email ? (email.split("@")[0] ?? email) : "Guest"; - const seed = session?.user?.id ?? guestSeed(); - return { name, color: colorForSeed(seed) }; + const user = session?.user ?? null; + const meta = (user?.user_metadata ?? {}) as { + full_name?: string; + name?: string; + avatar_url?: string; + }; + const email = user?.email ?? null; + const name = + meta.full_name ?? + meta.name ?? + (email ? (email.split("@")[0] ?? email) : "Guest"); + const seed = user?.id ?? guestSeed(); + return { name, color: colorForSeed(seed), avatarUrl: meta.avatar_url ?? null }; }, [session]); } diff --git a/apps/web/src/routes/Board.tsx b/apps/web/src/routes/Board.tsx index b59416f..d7abd70 100644 --- a/apps/web/src/routes/Board.tsx +++ b/apps/web/src/routes/Board.tsx @@ -13,12 +13,14 @@ import { SaveStatus } from "../features/canvas/SaveStatus"; import { SelectionInspector } from "../features/canvas/SelectionInspector"; import { useIdentity } from "../features/canvas/useIdentity"; import { ensureBoardOwnership } from "../features/board/boardOwnership"; +import { BoardAccessIndicator } from "../features/board/BoardAccessIndicator"; import { getSupabase } from "../lib/supabase"; export default function Board() { const { boardId } = useParams<{ boardId: string }>(); const [ready, setReady] = useState(false); const [owned, setOwned] = useState(false); + const [isPublic, setIsPublic] = useState(true); const identity = useIdentity(); const client = getSupabase(); const activePageId = usePageStore((s) => s.activePageId); @@ -43,7 +45,10 @@ export default function Board() { usePageStore.getState().initPages(boardId); setReady(true); // Claim board ownership when signed in β€” gates named snapshots. - void ensureBoardOwnership(client, boardId).then((r) => setOwned(r.owned)); + void ensureBoardOwnership(client, boardId).then((r) => { + setOwned(r.owned); + setIsPublic(r.isPublic); + }); }); }, [boardId, identity, client]); @@ -77,6 +82,12 @@ export default function Board() {
+ diff --git a/apps/web/src/routes/Home.tsx b/apps/web/src/routes/Home.tsx index cf5595c..f95c9cd 100644 --- a/apps/web/src/routes/Home.tsx +++ b/apps/web/src/routes/Home.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useMagicLink } from "../features/auth/useMagicLink"; +import { useAuth } from "../features/auth/useAuth"; import { supabaseConfigured } from "../env"; function newBoardId(): string { @@ -10,7 +10,8 @@ function newBoardId(): string { export default function Home() { const [email, setEmail] = useState(""); - const { status, error, session, sendMagicLink, signOut } = useMagicLink(); + const [showMagicLink, setShowMagicLink] = useState(false); + const { status, error, session, signInWithGoogle, sendMagicLink, signOut } = useAuth(); const navigate = useNavigate(); function onCreateBoard() { @@ -42,32 +43,77 @@ export default function Home() {
) : ( -
{ - e.preventDefault(); - if (email) void sendMagicLink(email); - }} - > - -
- setEmail(e.target.value)} - /> - + + {showMagicLink ? ( + { + e.preventDefault(); + if (email) void sendMagicLink(email); + }} + > +
+ setEmail(e.target.value)} + /> + +
+ + ) : ( + -
- {status === "sent" &&

Check your email to finish signing in.

} + )} + + {status === "sent" && ( +

Check your email to finish signing in.

+ )} {status === "error" && error &&

{error}

} - + )} ); } + +function GoogleIcon() { + return ( + + ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index edc25c0..035d6ae 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -146,6 +146,77 @@ a { color: #ff8a8a; } +.lg-button--google { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + background: #fff; + color: #1f1f1f; + border-color: rgba(0, 0, 0, 0.12); + font-weight: 500; +} + +.lg-button--google:hover { + background: #f5f5f5; +} + +.home__magic { + display: flex; + flex-direction: column; + gap: 8px; +} + +.home__link-button { + align-self: flex-start; + background: none; + border: none; + padding: 0; + color: var(--accent); + font-size: 12px; + cursor: pointer; + text-decoration: underline; +} + +.board-access { + position: fixed; + top: 16px; + right: 16px; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px 4px 12px; + border-radius: 999px; + background: var(--glass-tint, rgba(30, 30, 30, 0.75)); + border: 1px solid var(--glass-stroke, rgba(255, 255, 255, 0.12)); + backdrop-filter: blur(12px); + font-size: 12px; + z-index: 20; +} + +.board-access__label { + color: var(--fg-1, rgba(255, 255, 255, 0.7)); +} + +.board-access__toggle { + background: rgba(255, 255, 255, 0.12); + border: 1px solid var(--glass-stroke, rgba(255, 255, 255, 0.12)); + color: var(--fg-0, #fff); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; +} + +.board-access__toggle:hover { + background: rgba(255, 255, 255, 0.2); +} + +.board-access__toggle:disabled { + opacity: 0.5; + cursor: default; +} + .board { position: relative; width: 100%; diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..642f054 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,116 @@ +# NotUX β€” Online Setup (Sync, Sign-in, Google OAuth) + +NotUX runs fully offline in local-only mode. To enable sync, multiplayer, and +sign-in you need a Supabase project; to enable "Sign in with Google" you also +need a Google OAuth client. None of these secrets live in the repo β€” the +frontend only ever sees the public Supabase URL + anon key (injected at build +time), and `.env.local` is git-ignored. + +## 1. Supabase credentials (required for sync + sign-in) + +1. In the Supabase Dashboard, open **Project Settings β†’ API Keys**. +2. Copy the **Project URL** and the **Publishable / anon key**. +3. Create `apps/web/.env.local` (already git-ignored): + + ``` + VITE_SUPABASE_URL=https://.supabase.co + VITE_SUPABASE_ANON_KEY= + ``` + +The anon key is designed to be public β€” it is safe in the browser bundle +because Row Level Security (RLS) enforces all access rules server-side. The +**secret** key must never be placed in `.env.local` or the frontend. + +## 2. Apply the database migrations + +The schema (boards, pages, snapshots, assets, profiles) and all RLS policies +live in `supabase/migrations/`. Apply them to your project with the Supabase +CLI: + +```bash +supabase link --project-ref +supabase db push +``` + +`0004_profiles_and_privacy.sql` adds the `profiles` table (owner-only β€” a +user's account info is readable only by themselves) and a trigger that +auto-creates/refreshes a profile from auth metadata on every sign-in. + +## 3. Google OAuth (required for "Sign in with Google") + +### 3a. Create a Google OAuth client β€” **you must do this manually** + +1. Go to the [Google Cloud Console](https://console.cloud.google.com/) and + create (or pick) a project named e.g. **NotUX**. +2. **APIs & Services β†’ OAuth consent screen** β†’ configure (External), add your + email as a test user while in testing. +3. **APIs & Services β†’ Credentials β†’ Create Credentials β†’ OAuth client ID**: + - Application type: **Web application** + - **Authorized JavaScript origins**: + ``` + http://localhost:5173 + https://pedrobritx.github.io + https://.supabase.co + ``` + - **Authorized redirect URIs** (this is the Supabase callback, not the app): + ``` + https://.supabase.co/auth/v1/callback + ``` +4. Copy the **Client ID** and **Client Secret**. + +### 3b. Give them to Supabase β€” **you must do this manually** + +In the Supabase Dashboard: **Authentication β†’ Providers β†’ Google** β†’ paste the +Client ID and Client Secret, enable, and **Save**. + +(For local `supabase start`, the same values are read from the environment via +`supabase/config.toml`: export `SUPABASE_AUTH_GOOGLE_CLIENT_ID` and +`SUPABASE_AUTH_GOOGLE_SECRET` before starting the stack.) + +### 3c. Allowed redirect URLs + +Confirm **Authentication β†’ URL Configuration** includes: +- `http://localhost:5173/auth/callback` +- `https://pedrobritx.github.io/NotUX/auth/callback` + +These are already declared for local dev in `supabase/config.toml`. + +## 4. CI / GitHub Pages deploy + +The Pages workflow builds with the public Supabase values from repo secrets. In +**GitHub β†’ Settings β†’ Secrets and variables β†’ Actions**, set: + +- `VITE_SUPABASE_URL` +- `VITE_SUPABASE_ANON_KEY` + +(No Google value is needed at build time β€” OAuth is brokered entirely by +Supabase, so the frontend never holds the Google client ID or secret.) + +## 5. Verify + +```bash +pnpm install +pnpm dev # http://localhost:5173 +``` + +- Home shows **Sign in with Google** plus an email magic-link fallback. +- Signing in with Google redirects to `/auth/callback` and back, with your + name/avatar coming from the Google account. +- A new board you create while signed in is **πŸ”’ Private** (only you can see its + annotations). Use the **Share** chip (top-right of the board) to make it a + collaborative link anyone can edit in realtime. +- Pull the network connection: the status chip switches to *"Offline β€” saved + locally, will sync when reconnected"* and editing keeps working against + IndexedDB. + +## Privacy & security summary + +- **Account/login info** lives in `profiles`, gated by RLS to the owning user + only β€” no other user can read it. +- **Annotations** on a private board are reachable only by the board owner + (RLS predicate: `is_public OR owner_id = auth.uid()`). +- **Presence** (collaborator names/colors/avatars) is derived from each peer's + own session and broadcast peer-to-peer over Realtime awareness β€” never by + reading another user's profile row. +- **Secrets** never enter the repo: `.env`, `.env.local`, and `.env.*.local` + are git-ignored; only the public anon key reaches the browser. diff --git a/supabase/config.toml b/supabase/config.toml index 387c197..ad74934 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -19,7 +19,10 @@ file_size_limit = "25MiB" [auth] enabled = true site_url = "http://localhost:5173" -additional_redirect_urls = ["https://pedrobritx.github.io/NotUX/auth/callback"] +additional_redirect_urls = [ + "http://localhost:5173/auth/callback", + "https://pedrobritx.github.io/NotUX/auth/callback", +] jwt_expiry = 3600 enable_signup = true @@ -28,5 +31,14 @@ enable_signup = true double_confirm_changes = true enable_confirmations = true +# Google OAuth. Secrets are read from the environment (never committed); set +# SUPABASE_AUTH_GOOGLE_CLIENT_ID and SUPABASE_AUTH_GOOGLE_SECRET for local dev. +# For the hosted project these are configured in the Supabase Dashboard. +[auth.external.google] +enabled = true +client_id = "env(SUPABASE_AUTH_GOOGLE_CLIENT_ID)" +secret = "env(SUPABASE_AUTH_GOOGLE_SECRET)" +skip_nonce_check = false + [realtime] enabled = true diff --git a/supabase/migrations/0004_profiles_and_privacy.sql b/supabase/migrations/0004_profiles_and_privacy.sql new file mode 100644 index 0000000..9d0c574 --- /dev/null +++ b/supabase/migrations/0004_profiles_and_privacy.sql @@ -0,0 +1,140 @@ +-- NotUX profiles + privacy +-- +-- Adds a `profiles` table holding the signed-in user's identity (name, avatar, +-- provider) sourced from auth providers such as Google. A profile row is +-- private: only the user it belongs to can read or write it, so login/account +-- information is never exposed to other users. Presence names/colors shown to +-- collaborators come from each peer's own session broadcast over Realtime +-- awareness, never from reading another user's profile row. +-- +-- Also threads board privacy through the existing RLS: a board is reachable by +-- anyone when `is_public = true` (collaborative, the share model) OR by its +-- owner regardless of publicity (so a user's private boards and their +-- annotations are visible only to them until they choose to share). + +-- --------------------------------------------------------------------------- +-- profiles +-- --------------------------------------------------------------------------- +create table if not exists profiles ( + id uuid primary key references auth.users(id) on delete cascade, + email text, + display_name text, + avatar_url text, + provider text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table profiles enable row level security; + +-- A profile is strictly owner-only: read, insert, and update are gated to the +-- authenticated user whose id matches the row. +create policy profiles_select_own on profiles + for select using (id = auth.uid()); +create policy profiles_insert_own on profiles + for insert with check (id = auth.uid()); +create policy profiles_update_own on profiles + for update using (id = auth.uid()) with check (id = auth.uid()); + +-- Auto-provision (and keep fresh) a profile row whenever an auth user is +-- created or their metadata changes. SECURITY DEFINER so it runs above RLS. +-- Google populates raw_user_meta_data with full_name/name + avatar_url + the +-- provider is recorded in raw_app_meta_data.provider. +create or replace function public.handle_profile_upsert() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + insert into public.profiles (id, email, display_name, avatar_url, provider, updated_at) + values ( + new.id, + new.email, + coalesce( + new.raw_user_meta_data->>'full_name', + new.raw_user_meta_data->>'name', + split_part(coalesce(new.email, ''), '@', 1) + ), + new.raw_user_meta_data->>'avatar_url', + coalesce(new.raw_app_meta_data->>'provider', 'email'), + now() + ) + on conflict (id) do update set + email = excluded.email, + display_name = coalesce(excluded.display_name, profiles.display_name), + avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url), + provider = coalesce(excluded.provider, profiles.provider), + updated_at = now(); + return new; +end; +$$; + +drop trigger if exists on_auth_user_upserted on auth.users; +create trigger on_auth_user_upserted + after insert or update on auth.users + for each row execute function public.handle_profile_upsert(); + +-- --------------------------------------------------------------------------- +-- board privacy: owners always reach their own boards; public boards stay +-- free-for-all for collaboration. Replaces the publicity-only policies from +-- 0001 with owner-aware variants. +-- --------------------------------------------------------------------------- + +-- boards: owner can always read; otherwise must be public. +drop policy if exists boards_read_public on boards; +create policy boards_read_owner_or_public on boards + for select using (is_public = true or owner_id = auth.uid()); + +-- pages / snapshots / assets gate on the same predicate: public board OR caller +-- owns it. Private-board content (annotations) is thus owner-only. +drop policy if exists pages_rw_public on pages; +create policy pages_rw_accessible on pages + for all + using (exists ( + select 1 from boards b + where b.id = pages.board_id and (b.is_public or b.owner_id = auth.uid()) + )) + with check (exists ( + select 1 from boards b + where b.id = pages.board_id and (b.is_public or b.owner_id = auth.uid()) + )); + +drop policy if exists snapshots_read_public on snapshots; +create policy snapshots_read_accessible on snapshots + for select using (exists ( + select 1 from boards b + where b.id = snapshots.board_id and (b.is_public or b.owner_id = auth.uid()) + )); + +drop policy if exists snapshots_autosave_rw on snapshots; +create policy snapshots_autosave_rw on snapshots + for insert with check ( + kind = 'autosave' + and exists ( + select 1 from boards b + where b.id = snapshots.board_id and (b.is_public or b.owner_id = auth.uid()) + ) + ); + +drop policy if exists snapshots_autosave_update on snapshots; +create policy snapshots_autosave_update on snapshots + for update using ( + kind = 'autosave' + and exists ( + select 1 from boards b + where b.id = snapshots.board_id and (b.is_public or b.owner_id = auth.uid()) + ) + ); + +drop policy if exists assets_rw_public on assets; +create policy assets_rw_accessible on assets + for all + using (exists ( + select 1 from boards b + where b.id = assets.board_id and (b.is_public or b.owner_id = auth.uid()) + )) + with check (exists ( + select 1 from boards b + where b.id = assets.board_id and (b.is_public or b.owner_id = auth.uid()) + ));