diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 150dc39..b2c4c69 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -6,6 +6,7 @@ import { initializeTheme } from "../../lib/components/DarkMode"; import { SupabaseProvider } from "../../lib/supabase/SupabaseProvider"; import { UserMenu } from "../../lib/supabase/UserMenu"; import { AuthFlow } from "../../lib/supabase/AuthFlow"; +import { AuthCallback } from "../../lib/supabase/AuthCallback"; import { Settings } from "../../lib/supabase/Settings"; import { SubscriptionStatus } from "../../lib/supabase/SubscriptionStatus"; import { PricingCard } from "../../lib/supabase/PricingCard"; @@ -22,7 +23,7 @@ const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || ""; initializeTheme(); -type Page = "home" | "login" | "settings" | "pricing" | "gated"; +type Page = "home" | "login" | "settings" | "pricing" | "gated" | "auth-callback"; export function App() { if (!SUPABASE_URL || !SUPABASE_ANON_KEY) { @@ -46,8 +47,14 @@ export function App() { ); } +const AUTH_CALLBACK_PATH = new URL("auth/callback", document.baseURI).pathname; +const HOME_PATH = new URL(".", document.baseURI).pathname; + function DemoApp() { - const [page, setPage] = useState("home"); + const [page, setPage] = useState(() => { + if (window.location.pathname === AUTH_CALLBACK_PATH) return "auth-callback"; + return "home"; + }); return (
@@ -77,6 +84,15 @@ function PageContent({ page, setPage }: { page: Page; setPage: (p: Page) => void return ; case "gated": return ; + case "auth-callback": + return ( + { + window.history.replaceState({}, "", HOME_PATH); + setPage("home"); + }} + /> + ); default: return ; } @@ -126,7 +142,7 @@ function HomePage({ setPage }: { setPage: (p: Page) => void }) { function LoginPage({ onSuccess }: { onSuccess: () => void }) { return (
- +
); } diff --git a/docs/superpowers/plans/2026-04-10-magic-link-auth.md b/docs/superpowers/plans/2026-04-10-magic-link-auth.md new file mode 100644 index 0000000..6a94f62 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-magic-link-auth.md @@ -0,0 +1,907 @@ +# Magic-Link-First Auth Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the login/register tab-based auth with a magic-link-first unified flow, add an AuthCallback component for redirect handling, and add password management to Settings. + +**Architecture:** Three internal screens in AuthFlow (email entry, magic-link-sent, password login). New AuthCallback component for consuming apps to mount at their redirect route. New PasswordUpdate component embedded in Settings. All components use the existing SupabaseContext for the Supabase client. + +**Tech Stack:** React 19, TypeScript, Vitest + Testing Library, Supabase JS SDK (`signInWithOtp`, `signInWithPassword`, `updateUser`), Tailwind CSS, Storybook + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|----------------| +| Rewrite | `lib/supabase/AuthFlow.tsx` | Unified magic-link-first auth with 3 screens | +| Create | `lib/supabase/AuthFlow.test.tsx` | Tests for all AuthFlow screens | +| Create | `lib/supabase/AuthCallback.tsx` | Handles magic link redirect, establishes session | +| Create | `lib/supabase/AuthCallback.test.tsx` | Tests for AuthCallback | +| Create | `lib/supabase/PasswordUpdate.tsx` | Set/change password form | +| Create | `lib/supabase/PasswordUpdate.test.tsx` | Tests for PasswordUpdate | +| Modify | `lib/supabase/Settings.tsx` | Add PasswordUpdate section | +| Modify | `lib/supabase/index.ts` | Export new components | +| Modify | `lib/supabase/AuthFlow.stories.tsx` | Update stories for new screens | +| Modify | `demo/src/App.tsx` | Add auth callback route, pass redirectTo | + +--- + +### Task 1: AuthFlow — Write Tests + +**Files:** +- Create: `lib/supabase/AuthFlow.test.tsx` + +Tests mock the Supabase client via `MockSupabaseProvider`. The mock client from `createClient` has stub methods — we spy on them. + +- [ ] **Step 1: Create test file with mock helpers and first test** + +```tsx +// lib/supabase/AuthFlow.test.tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { AuthFlow } from "./AuthFlow"; +import { MockSupabaseProvider } from "./StorybookProvider"; + +function renderAuthFlow(props: React.ComponentProps = {}) { + return render( + + + , + ); +} + +describe("AuthFlow", () => { + describe("email screen (default)", () => { + it("renders email input and magic link button", () => { + renderAuthFlow(); + + expect(screen.getByPlaceholderText("Email")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /continue with magic link/i })).toBeInTheDocument(); + expect(screen.getByText(/sign in with password instead/i)).toBeInTheDocument(); + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run lib/supabase/AuthFlow.test.tsx` + +Expected: FAIL — the current AuthFlow doesn't have a "Continue with magic link" button. + +- [ ] **Step 3: Add remaining email screen tests** + +Append to the `describe("email screen")` block: + +```tsx + it("calls signInWithOtp when submitting email", async () => { + const user = userEvent.setup(); + renderAuthFlow({ redirectTo: "http://localhost/auth/callback" }); + + const emailInput = screen.getByPlaceholderText("Email"); + await user.type(emailInput, "test@example.com"); + await user.click(screen.getByRole("button", { name: /continue with magic link/i })); + + // We can't easily spy on the mock client's auth methods, so we verify + // the UI transitions to the "magic-link-sent" screen on success. + // The actual Supabase call is tested implicitly by the screen transition. + // For a proper integration test, we'd need a real Supabase instance. + }); +``` + +- [ ] **Step 4: Add password screen tests** + +Append to the `describe("AuthFlow")` block: + +```tsx + describe("password screen", () => { + it("switches to password screen when clicking the link", async () => { + const user = userEvent.setup(); + renderAuthFlow(); + + await user.click(screen.getByText(/sign in with password instead/i)); + + expect(screen.getByPlaceholderText("Email")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^login$/i })).toBeInTheDocument(); + expect(screen.getByText(/back to magic link/i)).toBeInTheDocument(); + }); + + it("pre-fills email from the email screen", async () => { + const user = userEvent.setup(); + renderAuthFlow(); + + const emailInput = screen.getByPlaceholderText("Email"); + await user.type(emailInput, "test@example.com"); + await user.click(screen.getByText(/sign in with password instead/i)); + + expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Email")).toHaveValue("test@example.com"); + }); + + it("switches back to email screen", async () => { + const user = userEvent.setup(); + renderAuthFlow(); + + await user.click(screen.getByText(/sign in with password instead/i)); + await user.click(screen.getByText(/back to magic link/i)); + + expect(screen.getByRole("button", { name: /continue with magic link/i })).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Password")).not.toBeInTheDocument(); + }); + }); +``` + +- [ ] **Step 5: Commit test file** + +```bash +git add lib/supabase/AuthFlow.test.tsx +git commit -m "test: add AuthFlow tests for magic-link-first redesign" +``` + +--- + +### Task 2: AuthFlow — Implement + +**Files:** +- Rewrite: `lib/supabase/AuthFlow.tsx` + +- [ ] **Step 1: Rewrite AuthFlow component** + +Replace the entire contents of `lib/supabase/AuthFlow.tsx`: + +```tsx +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Button } from "@/ui/Button"; +import { Input } from "@/ui/Input/Input"; +import { Alert } from "@/ui/Alert/Alert"; +import { cn } from "@/utils"; +import { useSupabaseContext } from "./context"; + +export interface AuthFlowProps { + /** Called after successful password login. Magic link logins are handled by AuthCallback. */ + onSuccess?: () => void; + /** URL that Supabase redirects to after the user clicks the magic link. */ + redirectTo?: string; + className?: string; +} + +type Screen = "email" | "magic-link-sent" | "password"; + +export function AuthFlow({ onSuccess, redirectTo, className }: AuthFlowProps) { + const { client } = useSupabaseContext(); + const [screen, setScreen] = useState("email"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleMagicLink(e: FormEvent) { + e.preventDefault(); + setError(null); + setIsSubmitting(true); + + try { + const { error } = await client.auth.signInWithOtp({ + email, + options: { emailRedirectTo: redirectTo }, + }); + if (error) throw error; + setScreen("magic-link-sent"); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsSubmitting(false); + } + } + + async function handlePasswordLogin(e: FormEvent) { + e.preventDefault(); + setError(null); + setIsSubmitting(true); + + try { + const { error } = await client.auth.signInWithPassword({ email, password }); + if (error) throw error; + onSuccess?.(); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsSubmitting(false); + } + } + + if (screen === "magic-link-sent") { + return ( +
+ + Check your email + + We sent a magic link to {email}. Click the link in your email to sign in. + + + +
+ ); + } + + if (screen === "password") { + return ( +
+ {error && ( + + {error} + + )} + +
+ setEmail(e.target.value)} + required + /> + setPassword(e.target.value)} + required + minLength={6} + /> + +
+ + +
+ ); + } + + // Default: email screen + return ( +
+ {error && ( + + {error} + + )} + +
+ setEmail(e.target.value)} + required + /> + +
+ + +
+ ); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run lib/supabase/AuthFlow.test.tsx` + +Expected: All tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add lib/supabase/AuthFlow.tsx +git commit -m "feat: redesign AuthFlow to magic-link-first" +``` + +--- + +### Task 3: AuthCallback — Write Tests + +**Files:** +- Create: `lib/supabase/AuthCallback.test.tsx` + +- [ ] **Step 1: Create test file** + +```tsx +// lib/supabase/AuthCallback.test.tsx +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AuthCallback } from "./AuthCallback"; +import { MockSupabaseProvider } from "./StorybookProvider"; + +function renderAuthCallback(props: React.ComponentProps = {}) { + return render( + + + , + ); +} + +describe("AuthCallback", () => { + it("shows a loading state initially", () => { + renderAuthCallback(); + + expect(screen.getByText(/signing you in/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run lib/supabase/AuthCallback.test.tsx` + +Expected: FAIL — `AuthCallback` doesn't exist yet. + +- [ ] **Step 3: Commit** + +```bash +git add lib/supabase/AuthCallback.test.tsx +git commit -m "test: add AuthCallback tests" +``` + +--- + +### Task 4: AuthCallback — Implement + +**Files:** +- Create: `lib/supabase/AuthCallback.tsx` + +- [ ] **Step 1: Create AuthCallback component** + +```tsx +// lib/supabase/AuthCallback.tsx +import { useCallback, useEffect, useState } from "react"; +import { Alert } from "@/ui/Alert/Alert"; +import { cn } from "@/utils"; +import { useSupabaseContext } from "./context"; + +export interface AuthCallbackProps { + /** Called when the session is successfully established. */ + onSuccess?: () => void; + /** Called when the magic link is invalid or expired. */ + onError?: (error: Error) => void; + className?: string; +} + +export function AuthCallback({ onSuccess, onError, className }: AuthCallbackProps) { + const { client, user } = useSupabaseContext(); + const [error, setError] = useState(null); + + // Memoize to avoid re-running effects when consumer doesn't stabilize callbacks. + // eslint-disable-next-line react-hooks/exhaustive-deps + const stableOnSuccess = useCallback(() => onSuccess?.(), []); + // eslint-disable-next-line react-hooks/exhaustive-deps + const stableOnError = useCallback((err: Error) => onError?.(err), []); + + // If SupabaseProvider already established the session (e.g. it processed the + // magic-link tokens before this component mounted), fire onSuccess immediately. + useEffect(() => { + if (user) { + stableOnSuccess(); + } + }, [user, stableOnSuccess]); + + useEffect(() => { + // Already signed in — no need to listen or timeout. + if (user) return; + + let settled = false; + + const { + data: { subscription }, + } = client.auth.onAuthStateChange((event) => { + if (event === "SIGNED_IN" && !settled) { + settled = true; + clearTimeout(timeout); + stableOnSuccess(); + } + }); + + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + const message = "Magic link expired or invalid. Please try again."; + setError(message); + stableOnError(new Error(message)); + }, 5000); + + return () => { + subscription.unsubscribe(); + clearTimeout(timeout); + }; + }, [client, user, stableOnSuccess, stableOnError]); + + if (error) { + return ( +
+ + Sign-in failed + {error} + +
+ ); + } + + return ( +
+

Signing you in...

+
+ ); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run lib/supabase/AuthCallback.test.tsx` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add lib/supabase/AuthCallback.tsx +git commit -m "feat: add AuthCallback component for magic link redirects" +``` + +--- + +### Task 5: PasswordUpdate — Write Tests + +**Files:** +- Create: `lib/supabase/PasswordUpdate.test.tsx` + +- [ ] **Step 1: Create test file** + +```tsx +// lib/supabase/PasswordUpdate.test.tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { PasswordUpdate } from "./PasswordUpdate"; +import { MockSupabaseProvider } from "./StorybookProvider"; + +describe("PasswordUpdate", () => { + it("renders nothing when no user is logged in", () => { + const { container } = render( + + + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders password form when user is logged in", () => { + render( + + + , + ); + + expect(screen.getByPlaceholderText("New password")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Confirm new password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /set password/i })).toBeInTheDocument(); + }); + + it("shows error when passwords do not match", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.type(screen.getByPlaceholderText("New password"), "newpass123"); + await user.type(screen.getByPlaceholderText("Confirm new password"), "different"); + await user.click(screen.getByRole("button", { name: /set password/i })); + + expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run lib/supabase/PasswordUpdate.test.tsx` + +Expected: FAIL — `PasswordUpdate` doesn't exist yet. + +- [ ] **Step 3: Commit** + +```bash +git add lib/supabase/PasswordUpdate.test.tsx +git commit -m "test: add PasswordUpdate tests" +``` + +--- + +### Task 6: PasswordUpdate — Implement + +**Files:** +- Create: `lib/supabase/PasswordUpdate.tsx` + +- [ ] **Step 1: Create PasswordUpdate component** + +```tsx +// lib/supabase/PasswordUpdate.tsx +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Button } from "@/ui/Button"; +import { Input } from "@/ui/Input/Input"; +import { Alert } from "@/ui/Alert/Alert"; +import { cn } from "@/utils"; +import { useSupabaseContext } from "./context"; + +export interface PasswordUpdateProps { + className?: string; +} + +export function PasswordUpdate({ className }: PasswordUpdateProps) { + const { client, user } = useSupabaseContext(); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + if (!user) return null; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(false); + + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + setIsSubmitting(true); + try { + const { error } = await client.auth.updateUser({ password }); + if (error) throw error; + setPassword(""); + setConfirmPassword(""); + setSuccess(true); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+

Password

+

Set or change your password

+
+ + {error && ( + + {error} + + )} + + {success && ( + + Password updated successfully. + + )} + +
+ setPassword(e.target.value)} + required + minLength={6} + /> + setConfirmPassword(e.target.value)} + required + minLength={6} + /> + +
+
+ ); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run lib/supabase/PasswordUpdate.test.tsx` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add lib/supabase/PasswordUpdate.tsx +git commit -m "feat: add PasswordUpdate component for settings" +``` + +--- + +### Task 7: Integrate PasswordUpdate into Settings + +**Files:** +- Modify: `lib/supabase/Settings.tsx` + +- [ ] **Step 1: Add PasswordUpdate to Settings** + +Replace the entire contents of `lib/supabase/Settings.tsx`: + +```tsx +import { ToggleDarkMode } from "@/components/DarkMode"; +import { cn } from "@/utils"; +import { PasswordUpdate } from "./PasswordUpdate"; + +export interface SettingsProps { + className?: string; +} + +export function Settings({ className }: SettingsProps) { + return ( +
+

Settings

+
+
+

Theme

+

Select your preferred color scheme

+
+ +
+ +
+ ); +} +``` + +- [ ] **Step 2: Run all tests** + +Run: `npx vitest run` + +Expected: All tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add lib/supabase/Settings.tsx +git commit -m "feat: add password management to Settings" +``` + +--- + +### Task 8: Update Library Exports + +**Files:** +- Modify: `lib/supabase/index.ts` + +- [ ] **Step 1: Add new exports** + +Add after the existing `AuthFlow` exports in `lib/supabase/index.ts`: + +```ts +export { AuthCallback } from "./AuthCallback"; +export type { AuthCallbackProps } from "./AuthCallback"; +export { PasswordUpdate } from "./PasswordUpdate"; +export type { PasswordUpdateProps } from "./PasswordUpdate"; +``` + +- [ ] **Step 2: Run all tests to verify nothing broke** + +Run: `npx vitest run` + +Expected: All tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add lib/supabase/index.ts +git commit -m "feat: export AuthCallback and PasswordUpdate from supabase" +``` + +--- + +### Task 9: Update Storybook Stories + +**Files:** +- Modify: `lib/supabase/AuthFlow.stories.tsx` + +- [ ] **Step 1: Rewrite AuthFlow stories for new screens** + +Replace the entire contents of `lib/supabase/AuthFlow.stories.tsx`: + +```tsx +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ToggleDarkModeIcon } from "@/components/DarkMode"; +import { AuthFlow } from "./AuthFlow"; +import { MockSupabaseProvider } from "./StorybookProvider"; + +const meta = { + title: "Supabase/AuthFlow", + component: AuthFlow, + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( + +
+
+ +
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + parameters: { + docs: { + description: { + story: "The default email entry screen. Users enter their email and receive a magic link.", + }, + }, + }, +}; + +export const WithRedirectTo: Story = { + args: { + redirectTo: "http://localhost:6006/auth/callback", + }, + parameters: { + docs: { + description: { + story: "AuthFlow with a redirectTo URL configured for the magic link.", + }, + }, + }, +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add lib/supabase/AuthFlow.stories.tsx +git commit -m "docs: update AuthFlow stories for magic-link-first design" +``` + +--- + +### Task 10: Update Demo App + +**Files:** +- Modify: `demo/src/App.tsx` + +- [ ] **Step 1: Add auth-callback page and update LoginPage** + +In `demo/src/App.tsx`, make these changes: + +1. Add `AuthCallback` import alongside the existing `AuthFlow` import: + +```ts +import { AuthCallback } from "../../lib/supabase/AuthCallback"; +``` + +2. Update the `Page` type to include the new route: + +```ts +type Page = "home" | "login" | "settings" | "pricing" | "gated" | "auth-callback"; +``` + +3. Add basename-aware URL constants before `DemoApp`: + +```ts +const AUTH_CALLBACK_PATH = new URL("auth/callback", document.baseURI).pathname; +const HOME_PATH = new URL(".", document.baseURI).pathname; +``` + +4. In `DemoApp`, detect the callback path on initial render by changing the `useState` init: + +```ts +const [page, setPage] = useState(() => { + if (window.location.pathname === AUTH_CALLBACK_PATH) return "auth-callback"; + return "home"; +}); +``` + +5. Add the `auth-callback` case to the `PageContent` switch, before the `default`: + +```tsx + case "auth-callback": + return ( + { + window.history.replaceState({}, "", HOME_PATH); + setPage("home"); + }} + /> + ); +``` + +6. Update `LoginPage` to pass `redirectTo`: + +```tsx +function LoginPage({ onSuccess }: { onSuccess: () => void }) { + return ( +
+ +
+ ); +} +``` + +- [ ] **Step 2: Run all tests** + +Run: `npx vitest run` + +Expected: All tests PASS. + +- [ ] **Step 3: Run lint** + +Run: `npx eslint lib/supabase/ demo/src/App.tsx --max-warnings 0` + +Expected: No errors or warnings. + +- [ ] **Step 4: Commit** + +```bash +git add demo/src/App.tsx +git commit -m "feat: update demo app with magic link auth callback" +``` diff --git a/docs/superpowers/specs/2026-04-10-magic-link-auth-design.md b/docs/superpowers/specs/2026-04-10-magic-link-auth-design.md new file mode 100644 index 0000000..fd3a89a --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-magic-link-auth-design.md @@ -0,0 +1,182 @@ +# Magic-Link-First Auth Flow Redesign + +## Summary + +Replace the current login/register tab-based auth flow with a magic-link-first +unified flow. The primary path is: enter email, receive magic link, click it, +done. Password login exists as a secondary option for users who have set a +password. Password management moves to Settings. + +## Motivation + +- Users who forget their password have no recovery path today. +- The login vs register distinction is unnecessary — Supabase's `signInWithOtp` + creates accounts automatically. +- A magic-link-first flow eliminates "forgot password" as a concept entirely. + +## Components + +### 1. AuthFlow (redesign of existing) + +**File:** `lib/supabase/AuthFlow.tsx` + +Replaces the current login/register tabs. Three internal screens, managed via +local state: + +#### Screen: "email" (default) + +- Email input field +- "Continue with magic link" button — calls + `client.auth.signInWithOtp({ email, options: { emailRedirectTo } })` +- Small text link: "Sign in with password instead" — switches to "password" + screen + +#### Screen: "magic-link-sent" + +- Success alert: "We sent a magic link to **{email}**. Click the link in your + email to sign in." +- "Back" link — returns to "email" screen + +#### Screen: "password" + +- Email input (pre-filled from "email" screen) +- Password input +- "Login" button — calls `client.auth.signInWithPassword({ email, password })` +- "Back to magic link" link — returns to "email" screen + +**Props (updated):** + +```ts +export interface AuthFlowProps { + /** Called after successful password login. Magic link logins are handled by AuthCallback. */ + onSuccess?: () => void; + /** URL that Supabase redirects to after the user clicks the magic link. */ + redirectTo?: string; + className?: string; +} +``` + +**Removed:** `mode` state (login/register tabs), `confirmPassword` field, +`registered` state. The register flow is gone — `signInWithOtp` handles both +new and existing users. + +### 2. AuthCallback (new) + +**File:** `lib/supabase/AuthCallback.tsx` + +A component that consuming apps mount at their magic-link redirect route (e.g., +`/auth/callback`). Responsibilities: + +1. On mount, detect auth tokens in the URL (Supabase appends them as hash + fragments or query params). +2. Supabase client's `onAuthStateChange` listener (already in + `SupabaseProvider`) handles the session automatically when the page loads + with valid tokens. `AuthCallback` just needs to: + - Show a loading state while the session is being established. + - Call `onSuccess` (or redirect) once the user is authenticated. + - Show an error if the link is invalid/expired. +3. Provide `onSuccess` and `onError` callbacks. + +```ts +export interface AuthCallbackProps { + /** Called when the session is successfully established. */ + onSuccess?: () => void; + /** Called when the magic link is invalid or expired. */ + onError?: (error: Error) => void; + className?: string; +} +``` + +**Implementation notes:** + +- Use `useEffect` to listen for `onAuthStateChange` events of type + `SIGNED_IN`. +- If no auth event fires within a reasonable timeout (~5s), show an error + message with a link back to login. + +### 3. PasswordUpdate (new) + +**File:** `lib/supabase/PasswordUpdate.tsx` + +A settings section for setting or changing the user's password. Embedded in the +`Settings` component directly, below the Theme setting. + +- "New password" input +- "Confirm new password" input +- "Set password" / "Update password" button — calls + `client.auth.updateUser({ password })` +- Success/error feedback inline + +```ts +export interface PasswordUpdateProps { + className?: string; +} +``` + +**Requires an active session.** If no user is logged in, renders nothing. + +### 4. Settings (updated) + +**File:** `lib/supabase/Settings.tsx` + +Add the `PasswordUpdate` section below the existing Theme setting. This keeps +Settings as a single component that includes all standard settings out of the +box. + +## Library Exports + +Add to `lib/supabase/index.ts`: + +```ts +export { AuthCallback } from "./AuthCallback"; +export type { AuthCallbackProps } from "./AuthCallback"; +export { PasswordUpdate } from "./PasswordUpdate"; +export type { PasswordUpdateProps } from "./PasswordUpdate"; +``` + +## Demo App Updates + +**File:** `demo/src/App.tsx` + +1. Update `LoginPage` to pass `redirectTo` prop pointing to the demo's callback + URL (e.g., `window.location.origin + "/auth/callback"`). +2. Add an `/auth/callback` page that renders `` and redirects to + home on success. +3. Settings page already renders `` which will now include password + management. + +Note: The demo app uses a simple `page` state variable for routing, not a URL +router. For magic link redirects to work in the demo, the callback page needs +to be detected from `window.location.pathname` on initial load, before the +React state takes over. Add a check at the top of `DemoApp` that reads +`window.location.pathname` and sets the initial page to `"auth-callback"` if it +matches. + +## Supabase Configuration Requirements + +Consuming apps must configure in their Supabase dashboard: + +- **Site URL**: The app's base URL +- **Redirect URLs**: Add the callback URL (e.g., + `https://myapp.com/auth/callback`) to the allow-list under Authentication > + URL Configuration + +## Error Handling + +- **Magic link send failure**: Show error inline in AuthFlow (e.g., rate + limiting, invalid email). +- **Expired/invalid magic link**: AuthCallback shows an error with a "Back to + login" link. +- **Password login failure**: Show error inline (wrong password, account not + found). +- **Password update failure**: Show error inline in PasswordUpdate (too short, + etc.). + +## What's NOT Included + +- No "Forgot password?" link or `resetPasswordForEmail` flow. Users who forget + their password log in via magic link and reset it in Settings. +- No email OTP (6-digit code) flow. Magic links only. +- No social auth (Google, GitHub, etc.). +- No changes to `SupabaseProvider`, hooks, or backend (Edge Functions, + migrations). diff --git a/lib/supabase/AuthCallback.test.tsx b/lib/supabase/AuthCallback.test.tsx new file mode 100644 index 0000000..37ddf1a --- /dev/null +++ b/lib/supabase/AuthCallback.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AuthCallback } from "./AuthCallback"; +import { MockSupabaseProvider } from "./StorybookProvider"; + +function renderAuthCallback(props: React.ComponentProps = {}) { + return render( + + + , + ); +} + +describe("AuthCallback", () => { + it("shows a loading state initially", () => { + renderAuthCallback(); + + expect(screen.getByText(/signing you in/i)).toBeInTheDocument(); + }); +}); diff --git a/lib/supabase/AuthCallback.tsx b/lib/supabase/AuthCallback.tsx new file mode 100644 index 0000000..64cb893 --- /dev/null +++ b/lib/supabase/AuthCallback.tsx @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useState } from "react"; +import { Alert } from "@/ui/Alert/Alert"; +import { cn } from "@/utils"; +import { useSupabaseContext } from "./context"; + +export interface AuthCallbackProps { + /** Called when the session is successfully established. */ + onSuccess?: () => void; + /** Called when the magic link is invalid or expired. */ + onError?: (error: Error) => void; + className?: string; +} + +export function AuthCallback({ onSuccess, onError, className }: AuthCallbackProps) { + const { client, user } = useSupabaseContext(); + const [error, setError] = useState(null); + + // Memoize to avoid re-running effects when consumer doesn't stabilize callbacks. + // eslint-disable-next-line react-hooks/exhaustive-deps + const stableOnSuccess = useCallback(() => onSuccess?.(), []); + // eslint-disable-next-line react-hooks/exhaustive-deps + const stableOnError = useCallback((err: Error) => onError?.(err), []); + + // If SupabaseProvider already established the session (e.g. it processed the + // magic-link tokens before this component mounted), fire onSuccess immediately. + useEffect(() => { + if (user) { + stableOnSuccess(); + } + }, [user, stableOnSuccess]); + + useEffect(() => { + // Already signed in — no need to listen or timeout. + if (user) return; + + let settled = false; + + const { + data: { subscription }, + } = client.auth.onAuthStateChange((event) => { + if (event === "SIGNED_IN" && !settled) { + settled = true; + clearTimeout(timeout); + stableOnSuccess(); + } + }); + + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + const message = "Magic link expired or invalid. Please try again."; + setError(message); + stableOnError(new Error(message)); + }, 5000); + + return () => { + subscription.unsubscribe(); + clearTimeout(timeout); + }; + }, [client, user, stableOnSuccess, stableOnError]); + + if (error) { + return ( +
+ + Sign-in failed + {error} + +
+ ); + } + + return ( +
+

Signing you in...

+
+ ); +} diff --git a/lib/supabase/AuthFlow.stories.tsx b/lib/supabase/AuthFlow.stories.tsx index 0467a4a..daf4b78 100644 --- a/lib/supabase/AuthFlow.stories.tsx +++ b/lib/supabase/AuthFlow.stories.tsx @@ -27,25 +27,24 @@ export default meta; type Story = StoryObj; -export const Login: Story = { +export const Default: Story = { parameters: { docs: { description: { - story: - "The default login tab. Submitting will show an error since there is no real Supabase backend connected.", + story: "The default email entry screen. Users enter their email and receive a magic link.", }, }, }, }; -export const WithClassName: Story = { +export const WithRedirectTo: Story = { args: { - className: "p-4 border border-dashed border-muted-foreground rounded", + redirectTo: "http://localhost:6006/auth/callback", }, parameters: { docs: { description: { - story: "AuthFlow with custom className applied.", + story: "AuthFlow with a redirectTo URL configured for the magic link.", }, }, }, diff --git a/lib/supabase/AuthFlow.test.tsx b/lib/supabase/AuthFlow.test.tsx new file mode 100644 index 0000000..fec80b4 --- /dev/null +++ b/lib/supabase/AuthFlow.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { AuthFlow } from "./AuthFlow"; +import { MockSupabaseProvider } from "./StorybookProvider"; + +function renderAuthFlow(props: React.ComponentProps = {}) { + return render( + + + , + ); +} + +describe("AuthFlow", () => { + describe("email screen (default)", () => { + it("renders email input and magic link button", () => { + renderAuthFlow(); + + expect(screen.getByPlaceholderText("Email")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /continue with magic link/i })).toBeInTheDocument(); + expect(screen.getByText(/sign in with password instead/i)).toBeInTheDocument(); + }); + }); + + describe("password screen", () => { + it("switches to password screen when clicking the link", async () => { + const user = userEvent.setup(); + renderAuthFlow(); + + await user.click(screen.getByText(/sign in with password instead/i)); + + expect(screen.getByPlaceholderText("Email")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^login$/i })).toBeInTheDocument(); + expect(screen.getByText(/back to magic link/i)).toBeInTheDocument(); + }); + + it("pre-fills email from the email screen", async () => { + const user = userEvent.setup(); + renderAuthFlow(); + + const emailInput = screen.getByPlaceholderText("Email"); + await user.type(emailInput, "test@example.com"); + await user.click(screen.getByText(/sign in with password instead/i)); + + expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Email")).toHaveValue("test@example.com"); + }); + + it("switches back to email screen", async () => { + const user = userEvent.setup(); + renderAuthFlow(); + + await user.click(screen.getByText(/sign in with password instead/i)); + await user.click(screen.getByText(/back to magic link/i)); + + expect(screen.getByRole("button", { name: /continue with magic link/i })).toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Password")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/lib/supabase/AuthFlow.tsx b/lib/supabase/AuthFlow.tsx index 67062fc..8483944 100644 --- a/lib/supabase/AuthFlow.tsx +++ b/lib/supabase/AuthFlow.tsx @@ -7,46 +7,51 @@ import { cn } from "@/utils"; import { useSupabaseContext } from "./context"; export interface AuthFlowProps { + /** Called after successful password login. Magic link logins are handled by AuthCallback. */ onSuccess?: () => void; + /** URL that Supabase redirects to after the user clicks the magic link. */ + redirectTo?: string; className?: string; } -export function AuthFlow({ onSuccess, className }: AuthFlowProps) { +type Screen = "email" | "magic-link-sent" | "password"; + +export function AuthFlow({ onSuccess, redirectTo, className }: AuthFlowProps) { const { client } = useSupabaseContext(); - const [mode, setMode] = useState<"login" | "register">("login"); + const [screen, setScreen] = useState("email"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [registered, setRegistered] = useState(false); - async function handleSubmit(e: FormEvent) { + async function handleMagicLink(e: FormEvent) { + e.preventDefault(); + setError(null); + setIsSubmitting(true); + + try { + const { error } = await client.auth.signInWithOtp({ + email, + options: { emailRedirectTo: redirectTo }, + }); + if (error) throw error; + setScreen("magic-link-sent"); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsSubmitting(false); + } + } + + async function handlePasswordLogin(e: FormEvent) { e.preventDefault(); setError(null); setIsSubmitting(true); try { - if (mode === "register") { - if (password !== confirmPassword) { - setError("Passwords do not match"); - setIsSubmitting(false); - return; - } - const { data, error } = await client.auth.signUp({ email, password }); - if (error) throw error; - if (data.user?.identities?.length === 0) { - setError( - "An account with this email already exists. Please log in or check your inbox for a confirmation link.", - ); - return; - } - setRegistered(true); - } else { - const { error } = await client.auth.signInWithPassword({ email, password }); - if (error) throw error; - onSuccess?.(); - } + const { error } = await client.auth.signInWithPassword({ email, password }); + if (error) throw error; + onSuccess?.(); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); } finally { @@ -54,104 +59,108 @@ export function AuthFlow({ onSuccess, className }: AuthFlowProps) { } } - if (registered) { + if (screen === "magic-link-sent") { return (
Check your email - We sent a confirmation link to {email}. Please verify your email to continue. + We sent a magic link to {email}. Click the link in your email to sign in.
); } - return ( -
-
- + if (screen === "password") { + return ( +
+ {error && ( + + {error} + + )} + +
+ setEmail(e.target.value)} + required + /> + setPassword(e.target.value)} + required + minLength={6} + /> + +
+
+ ); + } + // Default: email screen + return ( +
{error && ( {error} )} -
- setEmail(e.target.value)} required /> + setPassword(e.target.value)} + type="email" + placeholder="Email" + value={email} + onChange={(e) => setEmail(e.target.value)} required - minLength={6} /> - {mode === "register" && ( - setConfirmPassword(e.target.value)} - required - minLength={6} - /> - )}
+ +
); } diff --git a/lib/supabase/PasswordUpdate.test.tsx b/lib/supabase/PasswordUpdate.test.tsx new file mode 100644 index 0000000..8a24b77 --- /dev/null +++ b/lib/supabase/PasswordUpdate.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { PasswordUpdate } from "./PasswordUpdate"; +import { MockSupabaseProvider } from "./StorybookProvider"; + +describe("PasswordUpdate", () => { + it("renders nothing when no user is logged in", () => { + const { container } = render( + + + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders password form when user is logged in", () => { + render( + + + , + ); + + expect(screen.getByPlaceholderText("New password")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Confirm new password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /set password/i })).toBeInTheDocument(); + }); + + it("shows error when passwords do not match", async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.type(screen.getByPlaceholderText("New password"), "newpass123"); + await user.type(screen.getByPlaceholderText("Confirm new password"), "different"); + await user.click(screen.getByRole("button", { name: /set password/i })); + + expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument(); + }); +}); diff --git a/lib/supabase/PasswordUpdate.tsx b/lib/supabase/PasswordUpdate.tsx new file mode 100644 index 0000000..9f21efa --- /dev/null +++ b/lib/supabase/PasswordUpdate.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import type { FormEvent } from "react"; +import { Button } from "@/ui/Button"; +import { Input } from "@/ui/Input/Input"; +import { Alert } from "@/ui/Alert/Alert"; +import { cn } from "@/utils"; +import { useSupabaseContext } from "./context"; + +export interface PasswordUpdateProps { + className?: string; +} + +export function PasswordUpdate({ className }: PasswordUpdateProps) { + const { client, user } = useSupabaseContext(); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + if (!user) return null; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(false); + + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + setIsSubmitting(true); + try { + const { error } = await client.auth.updateUser({ password }); + if (error) throw error; + setPassword(""); + setConfirmPassword(""); + setSuccess(true); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+

Password

+

Set or change your password

+
+ + {error && ( + + {error} + + )} + + {success && ( + + Password updated successfully. + + )} + +
+ setPassword(e.target.value)} + required + minLength={6} + /> + setConfirmPassword(e.target.value)} + required + minLength={6} + /> + +
+
+ ); +} diff --git a/lib/supabase/Settings.tsx b/lib/supabase/Settings.tsx index a6c593e..f750ecf 100644 --- a/lib/supabase/Settings.tsx +++ b/lib/supabase/Settings.tsx @@ -1,5 +1,6 @@ import { ToggleDarkMode } from "@/components/DarkMode"; import { cn } from "@/utils"; +import { PasswordUpdate } from "./PasswordUpdate"; export interface SettingsProps { className?: string; @@ -16,6 +17,7 @@ export function Settings({ className }: SettingsProps) {
+
); } diff --git a/lib/supabase/Supabase.mdx b/lib/supabase/Supabase.mdx index c6ea8a2..60c2fc6 100644 --- a/lib/supabase/Supabase.mdx +++ b/lib/supabase/Supabase.mdx @@ -109,9 +109,19 @@ $$ language plpgsql security definer; In the Supabase dashboard under **Authentication > Settings**: -- Email/password sign-up is enabled by default - Set the **Site URL** to your production domain -- Add any additional **Redirect URLs** for local development (e.g., `http://localhost:5173`) +- Add **Redirect URLs** for your magic link callback route (e.g., `http://localhost:5173/auth/callback`, `https://your-app.com/auth/callback`) + +### 4. Deploy Email Templates + +Custom email templates for magic link sign-in and email confirmation are in `supabase/templates/`. To push them to your hosted project: + +```bash +supabase link --project-ref +supabase config push --config auth +``` + +This applies the template settings from `supabase/config.toml` to your hosted project. Review the config before pushing — it controls auth settings beyond just templates. ## Usage @@ -154,16 +164,44 @@ import { UserMenu } from "@fluffylabs/shared-ui/supabase"; ### Add the AuthFlow page +The auth flow is magic-link-first: users enter their email, receive a link, and click it to sign in. Password login is available as a secondary option for users who have set a password in Settings. + ```tsx import { AuthFlow } from "@fluffylabs/shared-ui/supabase"; function LoginPage() { - return navigate("/")} />; + return ( + navigate("/")} + redirectTo={window.location.origin + "/auth/callback"} + /> + ); +} +``` + +The `redirectTo` URL is where Supabase sends users after they click the magic link. You must add this URL to your Supabase project's **Redirect URLs** allow-list. + +### Add the AuthCallback page + +Mount `AuthCallback` at the route matching your `redirectTo` URL. It handles the token exchange and redirects on success. + +```tsx +import { AuthCallback } from "@fluffylabs/shared-ui/supabase"; + +function AuthCallbackPage() { + return ( + navigate("/")} + onError={(err) => console.error(err)} + /> + ); } ``` ### Add the Settings page +Settings includes theme selection and password management. Users who signed in via magic link can set a password here for convenience. + ```tsx import { Settings } from "@fluffylabs/shared-ui/supabase"; @@ -262,9 +300,11 @@ function MyComponent() { | Export | Description | |--------|-------------| | `SupabaseProvider` | Context provider — wraps your app, creates the Supabase client | -| `AuthFlow` | Combined login/register screen with tab toggle | +| `AuthFlow` | Magic-link-first auth screen with optional password login | +| `AuthCallback` | Handles magic link redirect — mount at your callback route | +| `PasswordUpdate` | Set/change password form (embedded in Settings) | | `UserMenu` | Header dropdown — login button (logged out) or email + settings + sign out (logged in) | -| `Settings` | Settings panel with theme selector (light/dark/auto) | +| `Settings` | Settings panel with theme selector and password management | | `useUser` | Returns `{ user, isLoading }` | | `useSession` | Returns `{ session, isLoading }` | | `useSignOut` | Returns a sign-out callback | diff --git a/lib/supabase/index.ts b/lib/supabase/index.ts index 0bca4f2..df6ae69 100644 --- a/lib/supabase/index.ts +++ b/lib/supabase/index.ts @@ -11,6 +11,10 @@ export { useQuota } from "./useQuota"; export type { UseQuotaOptions, UseQuotaResult } from "./useQuota"; export { AuthFlow } from "./AuthFlow"; export type { AuthFlowProps } from "./AuthFlow"; +export { AuthCallback } from "./AuthCallback"; +export type { AuthCallbackProps } from "./AuthCallback"; +export { PasswordUpdate } from "./PasswordUpdate"; +export type { PasswordUpdateProps } from "./PasswordUpdate"; export { UserMenu } from "./UserMenu"; export type { UserMenuProps } from "./UserMenu"; export { Settings } from "./Settings"; diff --git a/package-lock.json b/package-lock.json index d81af8f..4d1321f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,9 +67,15 @@ "vitest": "^4.1.0" }, "peerDependencies": { + "@supabase/supabase-js": "^2.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^3.1.0" + }, + "peerDependenciesMeta": { + "@supabase/supabase-js": { + "optional": true + } } }, "node_modules/@adobe/css-tools": { diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..807ed1c --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,381 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "fluffylabs-shared-ui" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "https://fluffylabs.dev/shared-ui/demo/" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://deploy-preview-*--fluffy-ui.netlify.app/demo/", "https://*.fluffylabs.dev/"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = true +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1m0s" +# Number of characters used in the email OTP. +otp_length = 8 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[auth.email.template.magic_link] +subject = "Sign in to Fluffy Labs" +content_path = "./supabase/templates/magic-link.html" + +[auth.email.template.confirmation] +subject = "Confirm your Fluffy Labs email" +content_path = "./supabase/templates/confirmation.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = true +verify_enabled = true + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/templates/confirmation.html b/supabase/templates/confirmation.html new file mode 100644 index 0000000..33b9bb6 --- /dev/null +++ b/supabase/templates/confirmation.html @@ -0,0 +1,49 @@ + + + + + + Confirm your email + + + + + + +
+ + + + + + + + + + + + + +
+

+ Confirm your email +

+

+ Please confirm your email address {{ .Email }} to complete your registration. +

+
+ + Confirm email + +
+

+ This link expires in 24 hours. If you didn't create an account, you can safely ignore this email. +

+

+ Fluffy Labs +

+
+
+ + diff --git a/supabase/templates/magic-link.html b/supabase/templates/magic-link.html new file mode 100644 index 0000000..141bb37 --- /dev/null +++ b/supabase/templates/magic-link.html @@ -0,0 +1,49 @@ + + + + + + Sign in to Fluffy Labs + + + + + + +
+ + + + + + + + + + + + + +
+

+ Sign in to Fluffy Labs +

+

+ Click the button below to sign in as {{ .Email }} +

+
+ + Sign in + +
+

+ This link expires in 1 hour. If you didn't request this, you can safely ignore this email. +

+

+ Fluffy Labs +

+
+
+ +