Wire the app to the Supabase backend, replacing localStorage-only

- DiagramRepository (renamed from ProjectRepository, per the
  organized-ideas.md §8 naming decision) now has a Supabase-backed
  implementation as the active repository. LocalStorageDiagramRepository
  stays in the codebase as a reference implementation / fallback, just
  no longer wired in. Only the storage layer's naming changed here --
  the domain type, store, and UI copy still say "Project"; that's a
  separate, larger mechanical rename tracked on its own.
- Minimal email/password auth gate (src/components/auth/LoginScreen.tsx)
  since Supabase RLS requires a real signed-in user to do anything --
  this is NOT the Phase 2 experience (Google SSO, polished signup),
  just enough of the same schema (username + email + password) to make
  the backend foundation usable end to end before that phase exists.
  Respects the hard email-verification gate from config.toml.
- .env.example documents the required VITE_SUPABASE_URL /
  VITE_SUPABASE_ANON_KEY (local dev values, not secrets); .env.local
  has the actual local values and is gitignored.

Verified end-to-end against the local stack: signup creates a
confirmed-pending user, the handle_new_user trigger creates their
profile, sign-in is blocked until confirmed, and a signed-in session
can upsert/read back its own diagram row exactly as the app's
save()/load() do it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
This commit is contained in:
2026-09-05 00:33:28 -05:00
co-authored by Claude Sonnet 5
parent 8cea3f8b92
commit 1ae967c8a4
13 changed files with 411 additions and 20 deletions
+5
View File
@@ -0,0 +1,5 @@
# Copy to .env.local (gitignored) and fill in. For local dev, run
# `npx supabase status` after `npx supabase start` to get these values —
# they're stable, well-known local dev credentials, not secrets.
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=
+108
View File
@@ -8,6 +8,7 @@
"name": "av-planner", "name": "av-planner",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.115.0",
"@xyflow/react": "^12.11.5", "@xyflow/react": "^12.11.5",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
@@ -714,6 +715,98 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/auth-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.115.0.tgz",
"integrity": "sha512-YNQlQWm1H0gsXHSY8Jd/xepBhjO0Zhwx04iW17A83/joQ5kFiUin6iPj9s9kZZvupnwLjXVP/diTFiLz2jUbwQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.115.0.tgz",
"integrity": "sha512-p97V6/YFcdp+zblFDVJaE8f9rGKTNz0PRzyJ2d1w/EYIU5lwidKuc3l/wM+u27ACmAC62albvoGiUGzLPSg7Aw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.115.0.tgz",
"integrity": "sha512-DdERcurLh5t84pgSywDg2LjLR5le7XAzl52/iQhC7FdboWDqGfDPMfaWJV3MHvhyxSlSNzbpSQG+RiQOBSn/4w==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.115.0.tgz",
"integrity": "sha512-5HyBkvlA/IUV2v8jX3uLTdy15jmTPRVXwXKoxvH2SKw5jZMNURxCxt+VpCEukscXQlY7pUpH8Cy4NH6io4iaRw==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "0.4.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.115.0.tgz",
"integrity": "sha512-dLyIxzbO+MCcKHhcce8rVUCQX1iyqXqQ8ytgkOVYJ7D+Zp0qKylPtQH3hamgxrGSxtDjaw47Urpzw2iK9PsKdA==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.115.0.tgz",
"integrity": "sha512-PYJSxtCo37R7tTZW6pAqsxUeSx/dlhA7zn8RzKEUSCqyTxCUhG+iHrDTb04UyVBYbttaUbhwkNTvb8h5HW+uZA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.115.0",
"@supabase/functions-js": "2.115.0",
"@supabase/postgrest-js": "2.115.0",
"@supabase/realtime-js": "2.115.0",
"@supabase/storage-js": "2.115.0"
},
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -1639,6 +1732,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2158,6 +2260,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.115.0",
"@xyflow/react": "^12.11.5", "@xyflow/react": "^12.11.5",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
+23
View File
@@ -1,5 +1,28 @@
import type { Session } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
import LoginScreen from './components/auth/LoginScreen'
import AppShell from './components/layout/AppShell' import AppShell from './components/layout/AppShell'
import { supabase } from './data/supabaseClient'
export default function App() { export default function App() {
// undefined = still checking for an existing session; null = signed out.
const [session, setSession] = useState<Session | null | undefined>(undefined)
useEffect(() => {
supabase.auth.getSession().then(({ data }) => setSession(data.session))
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => setSession(session))
return () => subscription.unsubscribe()
}, [])
if (session === undefined) {
return <div className="flex h-screen items-center justify-center text-sm text-slate-400">Loading…</div>
}
if (!session) {
return <LoginScreen />
}
return <AppShell /> return <AppShell />
} }
+141
View File
@@ -0,0 +1,141 @@
import { useState } from 'react'
import { supabase } from '../../data/supabaseClient'
/**
* Bare-bones email/password auth gate — just enough for a real signed-in
* session to exist so Supabase RLS (which requires auth.uid()) has someone
* to authorize. This is NOT the Phase 2 auth experience from
* organized-ideas.md §2 (Google SSO, polished signup, hard email-verification
* UX) — it's the minimum viable version of the same schema (username +
* email + password) so the backend foundation is actually usable end to end
* before that phase exists. Confirmation email respects the same
* hard-verification-gate config as Phase 2 will (supabase/config.toml).
*/
export default function LoginScreen() {
const [mode, setMode] = useState<'sign-in' | 'sign-up'>('sign-in')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [username, setUsername] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmSent, setConfirmSent] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
if (mode === 'sign-in') {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) throw error
} else {
const { error } = await supabase.auth.signUp({
email,
password,
options: { data: { username: username.trim() } },
})
if (error) throw error
setConfirmSent(true)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong.')
} finally {
setLoading(false)
}
}
if (confirmSent) {
return (
<div className="flex h-screen items-center justify-center bg-slate-50">
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 text-center shadow-sm">
<h1 className="text-sm font-semibold text-slate-800">Check your email</h1>
<p className="mt-2 text-xs text-slate-500">
We sent a confirmation link to <span className="font-medium">{email}</span>. You'll need to confirm
before you can sign in.
</p>
<p className="mt-2 text-[11px] text-slate-400">
Local dev: open Mailpit at{' '}
<a href="http://127.0.0.1:54324" className="underline" target="_blank" rel="noreferrer">
127.0.0.1:54324
</a>{' '}
to see it — no real email is sent.
</p>
<button
onClick={() => {
setConfirmSent(false)
setMode('sign-in')
}}
className="mt-4 text-xs font-medium text-indigo-600 hover:underline"
>
Back to sign in
</button>
</div>
</div>
)
}
return (
<div className="flex h-screen items-center justify-center bg-slate-50">
<form onSubmit={handleSubmit} className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h1 className="text-sm font-semibold text-indigo-700">AV Planner</h1>
<p className="mt-1 text-xs text-slate-500">{mode === 'sign-in' ? 'Sign in' : 'Create an account'}</p>
<div className="mt-4 space-y-3">
{mode === 'sign-up' && (
<label className="block text-xs font-medium text-slate-600">
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
)}
<label className="block text-xs font-medium text-slate-600">
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs font-medium text-slate-600">
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="mt-1 w-full rounded border border-slate-300 px-2 py-1.5 text-sm"
/>
</label>
</div>
{error && <p className="mt-3 text-xs text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="mt-4 w-full rounded bg-indigo-600 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Please wait…' : mode === 'sign-in' ? 'Sign in' : 'Create account'}
</button>
<button
type="button"
onClick={() => {
setMode(mode === 'sign-in' ? 'sign-up' : 'sign-in')
setError(null)
}}
className="mt-3 w-full text-center text-xs text-slate-500 hover:underline"
>
{mode === 'sign-in' ? "Don't have an account? Create one" : 'Already have an account? Sign in'}
</button>
</form>
</div>
)
}
+7
View File
@@ -1,5 +1,6 @@
import { useRef } from 'react' import { useRef } from 'react'
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport' import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
import { supabase } from '../../data/supabaseClient'
import { useProjectStore } from '../../state/projectStore' import { useProjectStore } from '../../state/projectStore'
export default function TopBar() { export default function TopBar() {
@@ -58,6 +59,12 @@ export default function TopBar() {
> >
Export Export
</button> </button>
<button
onClick={() => supabase.auth.signOut()}
className="rounded px-2.5 py-1.5 text-xs text-slate-500 hover:bg-slate-100"
>
Sign out
</button>
</div> </div>
</header> </header>
) )
+18
View File
@@ -0,0 +1,18 @@
import type { Project } from '../domain/types'
/**
* Storage abstraction the rest of the app codes against. `LocalStorageDiagramRepository`
* and `SupabaseDiagramRepository` both implement it — the store/UI layers don't need to
* know or care which one is active.
*
* Named for the "Diagram" terminology decided in organized-ideas.md §8 (renamed from
* "Project"). This is a deliberately scoped rename: only the storage layer's naming
* changed here. The domain type (`Project`), the Zustand store (`useProjectStore`), and
* user-facing copy still say "Project" — that's a separate, larger mechanical rename
* across the whole app, tracked as its own task rather than bundled into this one.
*/
export interface DiagramRepository {
load(): Promise<Project | null>
save(project: Project): Promise<void>
clear(): Promise<void>
}
@@ -1,10 +1,12 @@
import type { Project } from '../domain/types' import type { Project } from '../domain/types'
import type { DiagramRepository } from './DiagramRepository'
import { normalizeProject } from './exportImport' import { normalizeProject } from './exportImport'
import type { ProjectRepository } from './ProjectRepository'
const STORAGE_KEY = 'av-planner:project:v1' const STORAGE_KEY = 'av-planner:project:v1'
export class LocalStorageProjectRepository implements ProjectRepository { /** Kept around (no longer the active repository — see projectStore.ts) as a
* reference implementation and a safe fallback if Supabase is unreachable. */
export class LocalStorageDiagramRepository implements DiagramRepository {
async load(): Promise<Project | null> { async load(): Promise<Project | null> {
try { try {
const raw = window.localStorage.getItem(STORAGE_KEY) const raw = window.localStorage.getItem(STORAGE_KEY)
-13
View File
@@ -1,13 +0,0 @@
import type { Project } from '../domain/types'
/**
* Storage abstraction the rest of the app codes against. Today only
* `LocalStorageProjectRepository` implements it; a future backend would add
* e.g. `ApiProjectRepository` (REST/GraphQL) behind this same interface so
* the store/UI layers wouldn't need to change.
*/
export interface ProjectRepository {
load(): Promise<Project | null>
save(project: Project): Promise<void>
clear(): Promise<void>
}
+73
View File
@@ -0,0 +1,73 @@
import type { Project } from '../domain/types'
import type { DiagramRepository } from './DiagramRepository'
import { normalizeProject } from './exportImport'
import { supabase } from './supabaseClient'
/**
* Backs the app with the local (or eventually production) Supabase
* `diagrams` table instead of localStorage. The whole `Project` domain
* object is stored as-is in the `data` JSONB column — see the schema
* migration's header comment for why diagrams are a document, not
* normalized relational rows.
*
* Single-diagram scope for now, matching the app's current UI (one active
* project at a time): `load()` returns the signed-in user's most recently
* updated diagram, `save()` upserts by the diagram's own id. Multi-diagram
* management (list/switch between several) is a later phase.
*/
export class SupabaseDiagramRepository implements DiagramRepository {
async load(): Promise<Project | null> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return null
const { data: row, error } = await supabase
.from('diagrams')
.select('data')
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) {
console.error('Failed to load diagram from Supabase', error)
return null
}
if (!row) return null
return normalizeProject(row.data as Project)
}
async save(project: Project): Promise<void> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
console.error('Cannot save diagram: not signed in')
return
}
const { error } = await supabase.from('diagrams').upsert({
id: project.id,
name: project.name,
owner_id: user.id,
data: project,
updated_at: new Date().toISOString(),
})
if (error) {
console.error('Failed to save diagram to Supabase', error)
}
}
async clear(): Promise<void> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return
const { error } = await supabase.from('diagrams').delete().eq('owner_id', user.id)
if (error) {
console.error('Failed to clear diagrams from Supabase', error)
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { createClient } from '@supabase/supabase-js'
const url = import.meta.env.VITE_SUPABASE_URL
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!url || !anonKey) {
throw new Error(
'Missing VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY. Copy .env.example to .env.local and fill them in ' +
'(run `npx supabase start` then `npx supabase status` for local values).',
)
}
export const supabase = createClient(url, anonKey)
+8 -5
View File
@@ -3,12 +3,15 @@ import { create } from 'zustand'
import { validateConnection } from '../domain/compatibility' import { validateConnection } from '../domain/compatibility'
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyProject, incrementPortName } from '../domain/project' import { allDeviceTemplates, createDeviceFromTemplate, createEmptyProject, incrementPortName } from '../domain/project'
import type { CableType, Connection, Device, DeviceCategory, DeviceTemplate, Port, PortType, Project } from '../domain/types' import type { CableType, Connection, Device, DeviceCategory, DeviceTemplate, Port, PortType, Project } from '../domain/types'
import { LocalStorageProjectRepository } from '../data/LocalStorageProjectRepository' import type { DiagramRepository } from '../data/DiagramRepository'
import type { ProjectRepository } from '../data/ProjectRepository' import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
// Swapping to a backend later means constructing a different repository here // This is exactly the swap the repository pattern was built for: only this
// (or injecting one) — nothing else in the store changes. // one line changed to move off localStorage-only and onto real (locally
const repository: ProjectRepository = new LocalStorageProjectRepository() // self-hosted, for now) Supabase persistence — nothing else in the store or
// UI layers needed to change. LocalStorageDiagramRepository is still in the
// codebase as a reference implementation / fallback, just no longer active.
const repository: DiagramRepository = new SupabaseDiagramRepository()
interface ProjectStoreState { interface ProjectStoreState {
project: Project project: Project
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string
readonly VITE_SUPABASE_ANON_KEY: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}