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:
+23
@@ -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 { supabase } from './data/supabaseClient'
|
||||
|
||||
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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef } from 'react'
|
||||
import { downloadProjectFile, readProjectFile, ProjectImportError } from '../../data/exportImport'
|
||||
import { supabase } from '../../data/supabaseClient'
|
||||
import { useProjectStore } from '../../state/projectStore'
|
||||
|
||||
export default function TopBar() {
|
||||
@@ -58,6 +59,12 @@ export default function TopBar() {
|
||||
>
|
||||
Export
|
||||
</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>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
+4
-2
@@ -1,10 +1,12 @@
|
||||
import type { Project } from '../domain/types'
|
||||
import type { DiagramRepository } from './DiagramRepository'
|
||||
import { normalizeProject } from './exportImport'
|
||||
import type { ProjectRepository } from './ProjectRepository'
|
||||
|
||||
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> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -3,12 +3,15 @@ import { create } from 'zustand'
|
||||
import { validateConnection } from '../domain/compatibility'
|
||||
import { allDeviceTemplates, createDeviceFromTemplate, createEmptyProject, incrementPortName } from '../domain/project'
|
||||
import type { CableType, Connection, Device, DeviceCategory, DeviceTemplate, Port, PortType, Project } from '../domain/types'
|
||||
import { LocalStorageProjectRepository } from '../data/LocalStorageProjectRepository'
|
||||
import type { ProjectRepository } from '../data/ProjectRepository'
|
||||
import type { DiagramRepository } from '../data/DiagramRepository'
|
||||
import { SupabaseDiagramRepository } from '../data/SupabaseDiagramRepository'
|
||||
|
||||
// Swapping to a backend later means constructing a different repository here
|
||||
// (or injecting one) — nothing else in the store changes.
|
||||
const repository: ProjectRepository = new LocalStorageProjectRepository()
|
||||
// This is exactly the swap the repository pattern was built for: only this
|
||||
// one line changed to move off localStorage-only and onto real (locally
|
||||
// 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 {
|
||||
project: Project
|
||||
|
||||
Vendored
+10
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user