Skip to content

feat: add Supabase auth integration#72

Merged
tomusdrw merged 3 commits into
mainfrom
feat/supabase-auth
Mar 25, 2026
Merged

feat: add Supabase auth integration#72
tomusdrw merged 3 commits into
mainfrom
feat/supabase-auth

Conversation

@tomusdrw

Copy link
Copy Markdown
Member

Summary

  • Add @fluffylabs/shared-ui/supabase subpath with auth components powered by Supabase
  • SupabaseProvider: context provider — creates Supabase client, tracks auth state, holds appId for shared vs per-app data
  • AuthFlow: combined login/register screen with tab toggle
  • UserMenu: header dropdown — login button (logged out) or email + settings + sign out (logged in), with compact variant
  • Settings: settings panel with theme selector (light/dark/auto), reuses existing ToggleDarkMode
  • useUser / useSession / useSignOut / useUserData: hooks for auth state and per-user key-value storage
  • All apps share a single Supabase project (one user pool), with useUserData supporting both shared and app-scoped data
  • Supabase SDK is an optional peer dependency — apps not using auth are unaffected
  • Storybook stories and documentation (MDX getting-started page + README section) included

Configuration required

Consuming apps need to:

  1. npm install @supabase/supabase-js
  2. Create a user_data table with RLS in Supabase (SQL in docs)
  3. Wrap app with <SupabaseProvider supabaseUrl="..." supabaseAnonKey="..." appId="...">

Test plan

  • TypeScript compiles with no errors
  • ESLint passes with 0 warnings
  • Existing tests pass (9/9)
  • Library builds successfully (npm run build:lib)
  • Storybook starts and all new stories render
  • Manual testing with a real Supabase project (login, register, sign out, user data persistence)
  • Add unit tests for hooks and components (follow-up)

🤖 Generated with Claude Code

… data)

Add a new `@fluffylabs/shared-ui/supabase` subpath with authentication
components, user menu, settings, and per-user data storage powered by
Supabase. Kept as a separate subpath so apps that don't use auth are
unaffected.

New components:
- SupabaseProvider: context provider with appId for shared/per-app data
- AuthFlow: combined login/register screen with tab toggle
- UserMenu: header dropdown (login button or email/settings/sign-out)
- Settings: settings panel with theme selector (light/dark/auto)

New hooks:
- useUser, useSession, useSignOut
- useUserData: per-user key-value storage with appScoped option

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Mar 25, 2026

Copy link
Copy Markdown

Deploy Preview for fluffy-ui ready!

Name Link
🔨 Latest commit b078d9e
🔍 Latest deploy log https://app.netlify.com/projects/fluffy-ui/deploys/69c450488f93960008e263fa
😎 Deploy Preview https://deploy-preview-72--fluffy-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Mar 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f5ca4b6b-0b20-4cde-aa2b-a836118bc6a1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a comprehensive Supabase authentication and user data management feature set to the shared-ui library. It introduces an authentication provider, UI components for login/registration flows, user menu, settings panel, hooks for accessing auth state and user-scoped data persistence, comprehensive documentation, and Storybook stories. A new ./supabase subpath export is added to package.json with @supabase/supabase-js configured as an optional peer dependency.

Changes

Cohort / File(s) Summary
Documentation
README.md, lib/supabase/Supabase.mdx
Added comprehensive Supabase integration guide including setup instructions, user_data table schema with RLS policy, auth configuration, provider usage patterns, hook examples, and component export table.
Provider & Context
lib/supabase/SupabaseProvider.tsx, lib/supabase/context.ts, lib/supabase/types.ts
Introduced SupabaseProvider component managing client initialization, session fetching, and auth state subscription; defined typed SupabaseContext with AuthState, User, and Session types.
Authentication UI Components
lib/supabase/AuthFlow.tsx, lib/supabase/UserMenu.tsx, lib/supabase/Settings.tsx
Added AuthFlow component handling login/register forms with error handling and success callbacks; UserMenu dropdown for authenticated users with settings and sign-out actions; Settings panel with theme toggle.
Auth & Session Hooks
lib/supabase/useUser.ts, lib/supabase/useSession.ts, lib/supabase/useSignOut.ts
Implemented simple hooks exposing user and session state from context, plus memoized sign-out callback from Supabase client.
User Data Persistence Hook
lib/supabase/useUserData.ts
Added generic useUserData<T> hook enabling typed read/write access to per-user data table with app scoping support; includes save() and remove() methods with upsert/delete operations.
Storybook Stories & Utilities
lib/supabase/AuthFlow.stories.tsx, lib/supabase/UserMenu.stories.tsx, lib/supabase/Settings.stories.tsx, lib/supabase/StorybookProvider.tsx
Created Storybook stories demonstrating authentication states, user menu variations, and settings panel; introduced MockSupabaseProvider for consistent story decorators with mock auth state.
Public Exports & Build Config
lib/supabase/index.ts, package.json, vite.config.ts
Added barrel export module re-exporting all public components, hooks, and types; configured new ./supabase subpath in package exports; marked @supabase/* as external in Vite bundling.

Sequence Diagram(s)

sequenceDiagram
    participant App as App/Component
    participant Provider as SupabaseProvider
    participant Client as Supabase Client
    participant Auth as Supabase Auth

    App->>Provider: Mount with supabaseUrl, anonKey, appId
    Provider->>Client: createClient(url, key)
    Provider->>Auth: getSession()
    Auth-->>Provider: Return session & user
    Provider->>Provider: Set state (user, session, isLoading=false)
    Provider->>Auth: Subscribe to onAuthStateChange
    Auth-->>Provider: Auth state updates (sign-in/sign-out)
    Provider->>Provider: Update state on each auth event
    Provider-->>App: Expose context (client, user, session, isLoading, appId)
Loading
sequenceDiagram
    participant Component as Component
    participant AuthFlow as AuthFlow
    participant Client as Supabase Client
    participant Backend as Supabase Backend

    Component->>AuthFlow: User submits login/register form
    AuthFlow->>AuthFlow: Validate inputs (password match for registration)
    AuthFlow->>Client: signUp() or signInWithPassword()
    Client->>Backend: Send credentials
    Backend-->>Client: Return error or success response
    alt Login Success
        Client-->>AuthFlow: Return user & session
        AuthFlow->>Component: Call onSuccess()
    else Registration Success
        Client-->>AuthFlow: Confirmation required
        AuthFlow->>AuthFlow: Show email verification message
    else Error
        Backend-->>Client: Return error
        Client-->>AuthFlow: Display error alert
    end
Loading
sequenceDiagram
    participant Component as Component
    participant Hook as useUserData Hook
    participant Context as Supabase Context
    participant Client as Supabase Client
    participant DB as Supabase DB

    Component->>Hook: Call useUserData(key, options)
    Hook->>Context: useSupabaseContext()
    Context-->>Hook: Return client, user, appId
    
    alt User Logged In
        Hook->>Client: Query user_data table
        Client->>DB: SELECT value WHERE user_id=? AND key=? AND app_id=?
        DB-->>Client: Return row or null
        Client-->>Hook: Update state with data
        Hook-->>Component: Return {data, isLoading, error, save, remove}
        
        Component->>Hook: Call save(newValue)
        Hook->>Client: Upsert user_data
        Client->>DB: INSERT/UPDATE with onConflict
        DB-->>Client: Confirm
        Client-->>Hook: Resolve
        Hook->>Hook: Update cached data state
    else User Not Logged In
        Hook-->>Component: Return {data: null, isLoading: false, error: null}
        Component->>Hook: Call save() or remove()
        Hook-->>Component: Throw error
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Treeshaking improvement #63 — Both PRs modify package.json exports and vite.config.ts external dependencies configuration to expose new subpath exports from the library.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add Supabase auth integration' directly and accurately summarizes the main change in the changeset—adding a complete Supabase authentication integration as a new package subpath.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing all components, hooks, configuration, and testing status for the Supabase integration feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/supabase-auth

Comment @coderabbitai help to get the list of available commands and usage tips.

- Handle getSession() failure in SupabaseProvider so isLoading doesn't
  stay true indefinitely on network errors
- Add error handling for signOut() in UserMenu
- Clear password fields when switching between login/register tabs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (7)
lib/supabase/useSignOut.ts (1)

4-7: Consider returning the Promise for error handling.

client.auth.signOut() returns a Promise that may contain an error. Currently, any sign-out failures are silently ignored. Returning the Promise allows consumers to handle errors if needed.

💡 Suggested change
 export function useSignOut() {
   const { client } = useSupabaseContext();
-  return useCallback(() => client.auth.signOut(), [client]);
+  return useCallback(() => client.auth.signOut(), [client]) as () => Promise<{ error: Error | null }>;
 }

Alternatively, if you want to keep the simple void signature, consider logging errors internally:

return useCallback(async () => {
  const { error } = await client.auth.signOut();
  if (error) console.error("Sign out failed:", error);
}, [client]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/useSignOut.ts` around lines 4 - 7, The current useSignOut
implementation calls client.auth.signOut() but does not return its Promise, so
callers cannot handle errors; update useSignOut (and the useCallback it returns)
to return the Promise from client.auth.signOut() so callers can await or catch
errors (reference: useSignOut, useCallback, client.auth.signOut,
useSupabaseContext). Alternatively, if you prefer a void API, change the
callback to async, await client.auth.signOut(), and handle/log the returned {
error } internally (e.g., console.error on failure) before returning.
package.json (1)

149-158: Consider tightening the peer dependency version range.

The peer dependency ^2.0.0 is quite permissive and may include older SDK versions with different APIs. Since you're developing against ^2.100.0, consider using a narrower range like ^2.50.0 or ^2.100.0 to ensure API compatibility with the hooks and components in this integration.

💡 Suggested change
   "peerDependencies": {
-    "@supabase/supabase-js": "^2.0.0",
+    "@supabase/supabase-js": "^2.50.0",
     "react": "^19.0.0",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 149 - 158, Update the peer dependency range for
`@supabase/supabase-js` in package.json to match the SDK version you developed
against (e.g., change "@supabase/supabase-js": "^2.0.0" to a tighter range like
"^2.100.0" or at minimum "^2.50.0") so consumers install a compatible API
surface; keep the peerDependenciesMeta optional flag as-is.
lib/supabase/Settings.stories.tsx (1)

37-40: Consider adding a docs description for consistency.

The WithClassName story in AuthFlow.stories.tsx includes a parameters.docs.description.story property, but this one doesn't. Adding one would maintain consistency across the Supabase stories.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/Settings.stories.tsx` around lines 37 - 40, Add a docs
description for the WithClassName Story: update the exported Story object
"WithClassName" in Settings.stories.tsx to include a
parameters.docs.description.story string (similar to AuthFlow.stories.tsx) that
briefly describes what the story demonstrates (e.g., showing custom className
styling). Ensure you add it to the WithClassName Story's args/parameters block
so it appears in the Storybook docs.
lib/supabase/Supabase.mdx (1)

26-45: Consider adding a trigger to auto-update updated_at.

The updated_at column is defined but won't automatically update on row changes. Consider adding a trigger function:

create or replace function update_updated_at()
returns trigger as $$
begin
  new.updated_at = now();
  return new;
end;
$$ language plpgsql;

create trigger user_data_updated_at
  before update on user_data
  for each row execute function update_updated_at();

Without this, updated_at will always reflect the initial created_at value.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/Supabase.mdx` around lines 26 - 45, The user_data table's
updated_at column won't change on updates; add a PL/pgSQL trigger function
update_updated_at() that sets NEW.updated_at = now() and attach it as a BEFORE
UPDATE trigger (name it user_data_updated_at) on table user_data so updated_at
is refreshed automatically on each row update; implement the function (returns
trigger) and the trigger creation referencing the user_data table,
update_updated_at function, and the updated_at column.
lib/supabase/StorybookProvider.tsx (1)

27-39: Consider creating the Supabase client outside useMemo.

Creating createClient inside useMemo means a new client instance is created whenever user, isLoading, or appId changes. While this is fine for Storybook where these rarely change mid-story, extracting client creation improves clarity:

♻️ Proposed refactor
+const mockClient = createClient(PLACEHOLDER_URL, PLACEHOLDER_KEY);
+
 export function MockSupabaseProvider({
   children,
   appId = "storybook",
   user = null,
   isLoading = false,
 }: MockSupabaseProviderProps) {
   const value = useMemo(() => {
-    const client = createClient(PLACEHOLDER_URL, PLACEHOLDER_KEY);
     const mockUser = user ? ({ id: "mock-user-id", email: "user@example.com", ...user } as User) : null;
     const mockSession = mockUser ? ({ user: mockUser, access_token: "mock-token" } as Session) : null;

     return {
-      client,
+      client: mockClient,
       user: mockUser,
       session: mockSession,
       isLoading,
       appId,
     };
   }, [user, isLoading, appId]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/StorybookProvider.tsx` around lines 27 - 39, The Supabase client
is being constructed inside the useMemo that produces value (const value =
useMemo(...)), which recreates the client whenever user, isLoading, or appId
changes; move client creation out of that memo so the client instance is stable
— either create a single shared instance outside the component (e.g., a
module-level createClient call) or initialize it once inside the component with
useRef, then return that stable client from the value object (keep references to
createClient, useMemo, and value to locate the change) and remove the client
from the useMemo dependency list so only user/session/isLoading/appId drive the
memoized value.
lib/supabase/UserMenu.tsx (1)

51-54: Consider handling sign-out errors.

The signOut() call doesn't handle potential errors. If sign-out fails (e.g., network issue), the user receives no feedback. Consider wrapping in try/catch or checking the return value.

♻️ Proposed improvement
-<DropdownMenuItem onClick={() => signOut()}>
+<DropdownMenuItem onClick={async () => {
+  try {
+    await signOut();
+  } catch {
+    // Optionally show error toast/notification
+  }
+}}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/UserMenu.tsx` around lines 51 - 54, The DropdownMenuItem
currently calls signOut() without handling failures; change its onClick to an
async handler that awaits signOut() (from the auth client), wrap the call in
try/catch, and handle errors by logging (e.g., console.error or process logger)
and showing user feedback (toast/alert/inline error) on failure; also consider
setting a local loading state to disable the menu item while the async signOut
(referenced in this component as signOut) is pending to prevent duplicate
invocations.
lib/supabase/useUserData.ts (1)

53-59: Type assertion row?.value as T lacks runtime validation.

The hook trusts that the JSONB value in the database matches the generic type T. If the stored data has a different shape (e.g., schema evolved, manual DB edit), consumers will receive incorrectly typed data. Consider documenting this limitation or providing an optional validation function in UseUserDataOptions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/useUserData.ts` around lines 53 - 59, The code currently casts
row?.value as T without runtime validation (in the query.maybeSingle().then
callback where setResult is called), which can pass malformed JSON to consumers;
add an optional validator/transform function to the UseUserDataOptions (e.g.,
validate?: (v: unknown) => v is T or parse?: (v: unknown) => T) and, inside the
query.maybeSingle() handler, run that validator/transform on row?.value before
assigning to setResult.data—if validation fails, setResult should return data:
null and an error describing validation failure (or fall back to documented
behavior if no validator provided); also update UseUserDataOptions documentation
to mention the limitation when no validator is supplied.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/supabase/AuthFlow.tsx`:
- Around line 36-38: The signUp flow in AuthFlow.tsx currently treats any
non-error response as a successful registration; adjust the client.auth.signUp
handling to destructure the full response (data, error), keep throwing on error,
then detect Supabase's duplicate-email fake user by checking
data.user?.identities?.length === 0 and handle it as a non-created account
(e.g., call setError('Email already registered. Check your inbox for the
confirmation link.') and return) instead of calling setRegistered(true);
otherwise continue to setRegistered(true) for real new accounts.

In `@lib/supabase/SupabaseProvider.tsx`:
- Around line 21-25: The client.auth.getSession() promise call lacks error
handling; wrap the call in a try/catch or attach a .catch handler around
client.auth.getSession() in SupabaseProvider.tsx (the block that calls
setSession, setUser, setIsLoading) to handle failures: on error log the error
(or surface it via context/state), setUser(null)/setSession(null) if
appropriate, and ensure setIsLoading(false) runs in the error path so loading
state is cleared; reference the client.auth.getSession() invocation and the
setSession/setUser/setIsLoading calls when making this change.
- Around line 20-36: Remove the redundant client.auth.getSession() call and
instead rely only on client.auth.onAuthStateChange within the useEffect; inside
the onAuthStateChange callback check the event parameter for "INITIAL_SESSION"
to perform the initial setSession/setUser/setIsLoading(false) and for other
events only update session/user (but not setIsLoading), and keep returning () =>
subscription.unsubscribe() to clean up; update references in the effect to use
the existing setSession, setUser, setIsLoading and the subscription variable
from client.auth.onAuthStateChange.

In `@lib/supabase/UserMenu.tsx`:
- Line 39: The span currently renders undefined when user.email is missing;
update the expression in the UserMenu component (the span that shows email) to
use a safe fallback before splitting — e.g., derive a local email string like
const email = user.email ?? '' and then render compact ? email.split("@")[0] :
email (or render a visible fallback like '—'/'Unknown' if you prefer) so
splitting never receives undefined and the UI doesn't show "undefined".

In `@lib/supabase/useUserData.ts`:
- Around line 40-65: When user becomes null the useEffect currently returns
early and leaves prior result.data intact; update the effect in useUserData.ts
so that when user is falsy you immediately clear state by calling setResult({
data: null, error: null, fetchKey }) (or equivalent) before returning. Modify
the effect that references client, user, key, effectiveAppId, fetchKey (inside
the useEffect block that builds query and calls query.maybeSingle) to handle the
user === null branch by resetting result via setResult so stale data is not
shown after logout.
- Around line 71-73: The upsert uses effectiveAppId which can be null so
PostgreSQL treats NULLs as distinct and inserts duplicates; update the logic in
useUserData.ts (the save/upsert path where client.from("user_data").upsert(...)
is called) to normalize shared/null app IDs to a deterministic sentinel (e.g.,
empty string "") before calling upsert (i.e., replace effectiveAppId === null
with the sentinel) so the unique constraint on (user_id, app_id, key) works as
intended, or alternatively implement the partial unique index approach in DB
migration if you prefer not to use a sentinel—pick one strategy and apply it
consistently where effectiveAppId is used.

---

Nitpick comments:
In `@lib/supabase/Settings.stories.tsx`:
- Around line 37-40: Add a docs description for the WithClassName Story: update
the exported Story object "WithClassName" in Settings.stories.tsx to include a
parameters.docs.description.story string (similar to AuthFlow.stories.tsx) that
briefly describes what the story demonstrates (e.g., showing custom className
styling). Ensure you add it to the WithClassName Story's args/parameters block
so it appears in the Storybook docs.

In `@lib/supabase/StorybookProvider.tsx`:
- Around line 27-39: The Supabase client is being constructed inside the useMemo
that produces value (const value = useMemo(...)), which recreates the client
whenever user, isLoading, or appId changes; move client creation out of that
memo so the client instance is stable — either create a single shared instance
outside the component (e.g., a module-level createClient call) or initialize it
once inside the component with useRef, then return that stable client from the
value object (keep references to createClient, useMemo, and value to locate the
change) and remove the client from the useMemo dependency list so only
user/session/isLoading/appId drive the memoized value.

In `@lib/supabase/Supabase.mdx`:
- Around line 26-45: The user_data table's updated_at column won't change on
updates; add a PL/pgSQL trigger function update_updated_at() that sets
NEW.updated_at = now() and attach it as a BEFORE UPDATE trigger (name it
user_data_updated_at) on table user_data so updated_at is refreshed
automatically on each row update; implement the function (returns trigger) and
the trigger creation referencing the user_data table, update_updated_at
function, and the updated_at column.

In `@lib/supabase/UserMenu.tsx`:
- Around line 51-54: The DropdownMenuItem currently calls signOut() without
handling failures; change its onClick to an async handler that awaits signOut()
(from the auth client), wrap the call in try/catch, and handle errors by logging
(e.g., console.error or process logger) and showing user feedback
(toast/alert/inline error) on failure; also consider setting a local loading
state to disable the menu item while the async signOut (referenced in this
component as signOut) is pending to prevent duplicate invocations.

In `@lib/supabase/useSignOut.ts`:
- Around line 4-7: The current useSignOut implementation calls
client.auth.signOut() but does not return its Promise, so callers cannot handle
errors; update useSignOut (and the useCallback it returns) to return the Promise
from client.auth.signOut() so callers can await or catch errors (reference:
useSignOut, useCallback, client.auth.signOut, useSupabaseContext).
Alternatively, if you prefer a void API, change the callback to async, await
client.auth.signOut(), and handle/log the returned { error } internally (e.g.,
console.error on failure) before returning.

In `@lib/supabase/useUserData.ts`:
- Around line 53-59: The code currently casts row?.value as T without runtime
validation (in the query.maybeSingle().then callback where setResult is called),
which can pass malformed JSON to consumers; add an optional validator/transform
function to the UseUserDataOptions (e.g., validate?: (v: unknown) => v is T or
parse?: (v: unknown) => T) and, inside the query.maybeSingle() handler, run that
validator/transform on row?.value before assigning to setResult.data—if
validation fails, setResult should return data: null and an error describing
validation failure (or fall back to documented behavior if no validator
provided); also update UseUserDataOptions documentation to mention the
limitation when no validator is supplied.

In `@package.json`:
- Around line 149-158: Update the peer dependency range for
`@supabase/supabase-js` in package.json to match the SDK version you developed
against (e.g., change "@supabase/supabase-js": "^2.0.0" to a tighter range like
"^2.100.0" or at minimum "^2.50.0") so consumers install a compatible API
surface; keep the peerDependenciesMeta optional flag as-is.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 610fcffe-0df4-46fd-b15b-9acfc6b572fb

📥 Commits

Reviewing files that changed from the base of the PR and between a7b1fdb and c87cbb8.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (19)
  • README.md
  • lib/supabase/AuthFlow.stories.tsx
  • lib/supabase/AuthFlow.tsx
  • lib/supabase/Settings.stories.tsx
  • lib/supabase/Settings.tsx
  • lib/supabase/StorybookProvider.tsx
  • lib/supabase/Supabase.mdx
  • lib/supabase/SupabaseProvider.tsx
  • lib/supabase/UserMenu.stories.tsx
  • lib/supabase/UserMenu.tsx
  • lib/supabase/context.ts
  • lib/supabase/index.ts
  • lib/supabase/types.ts
  • lib/supabase/useSession.ts
  • lib/supabase/useSignOut.ts
  • lib/supabase/useUser.ts
  • lib/supabase/useUserData.ts
  • package.json
  • vite.config.ts

Comment thread lib/supabase/AuthFlow.tsx Outdated
Comment on lines +36 to +38
const { error } = await client.auth.signUp({ email, password });
if (error) throw error;
setRegistered(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Supabase signUp return value when email confirmation required

💡 Result:

When email confirmation is required (enabled in project settings), the Supabase auth.signUp method returns { data: { user: User, session: null }, error: null } on success. The user object represents the newly created (unconfirmed) user, and no session is established until the user confirms their email via the link sent to them. This behavior is documented in the official JavaScript API reference. Example response structure: { data: { user: { id: "uuid", aud: "authenticated", role: "authenticated", email: "example@email.com", email_confirmed_at: null, phone: "", // ... other user fields identities: [] // empty array for new/pending users }, session: null }, error: null } If the email already exists and is confirmed, it returns an obfuscated/fake user object (with identities: []) instead of an error, to prevent email enumeration attacks. A new confirmation email is typically resent. To detect duplicates client-side (not recommended for security), check data.user.identities?.length === 0. No session is returned because the user cannot sign in until email is confirmed.

Citations:


Handle the case when email already exists with unconfirmed status.

Supabase's signUp returns { error: null } when an email already exists, rather than an error. The current code shows a "registered" success message in this case, which is misleading—the user hasn't actually created a new account. To properly handle this, destructure the full response and check data.user.identities?.length === 0 to detect duplicate emails (Supabase intentionally returns a fake user object to prevent email enumeration):

Suggested check
const { data, error } = await client.auth.signUp({ email, password });
if (error) throw error;
if (data.user?.identities?.length === 0) {
  // Email already exists; new confirmation email is being sent
  setError('Email already registered. Check your inbox for the confirmation link.');
  return;
}
setRegistered(true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/AuthFlow.tsx` around lines 36 - 38, The signUp flow in
AuthFlow.tsx currently treats any non-error response as a successful
registration; adjust the client.auth.signUp handling to destructure the full
response (data, error), keep throwing on error, then detect Supabase's
duplicate-email fake user by checking data.user?.identities?.length === 0 and
handle it as a non-created account (e.g., call setError('Email already
registered. Check your inbox for the confirmation link.') and return) instead of
calling setRegistered(true); otherwise continue to setRegistered(true) for real
new accounts.

Comment on lines +20 to +36
useEffect(() => {
client.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setIsLoading(false);
});

const {
data: { subscription },
} = client.auth.onAuthStateChange((_event, session) => {
setSession(session);
setUser(session?.user ?? null);
setIsLoading(false);
});

return () => subscription.unsubscribe();
}, [client]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Supabase JS onAuthStateChange INITIAL_SESSION event behavior

💡 Result:

The Supabase JS onAuthStateChange (also referred to as onAuthStateChanged in some contexts) emits an INITIAL_SESSION event right after the Supabase client is constructed and the initial session is loaded from storage (typically localStorage in browsers). This event provides the current session state at initialization, which could be a valid session (user signed in) or null (no user signed in). It allows developers to handle the initial auth state synchronously when the listener is first registered. Key behaviors: - Fires immediately upon calling supabase.auth.onAuthStateChange(callback), before other auth events. - Session in callback: Valid Session object if user was previously signed in (restored from storage), or null if no session exists. - Subsequent events like SIGNED_IN, SIGNED_OUT, etc., fire on auth changes (sign in/out, token refresh, etc.). - Recommendation: Check event === 'INITIAL_SESSION' and use the provided session to initialize app state (e.g., set user or show login). - Avoid async operations or other Supabase calls directly in the callback to prevent deadlocks; use setTimeout if needed. - Pairs well with supabase.auth.getSession for immediate checks, but INITIAL_SESSION ensures listener-based handling. Example usage: supabase.auth.onAuthStateChange((event, session) => { if (event === 'INITIAL_SESSION') { // Initialize UI with session (signed in or null) console.log('Initial session:', session); } else if (event === 'SIGNED_IN') { // Handle new sign-in } }); This is the standard pattern for client-side auth state management in React/Vue/etc. apps.

Citations:


🏁 Script executed:

cat -n lib/supabase/SupabaseProvider.tsx | head -50

Repository: FluffyLabs/shared-ui

Length of output: 1756


Redundant state updates from concurrent getSession() and onAuthStateChange.

Both getSession() and onAuthStateChange update the same state variables on mount, causing double state updates and renders. The onAuthStateChange listener fires INITIAL_SESSION immediately upon subscription with the current session, making the getSession() call redundant. Additionally, the callback doesn't filter by event type, so it updates loading state on every auth change (not just initial), which defeats the purpose of tracking isLoading.

Instead, rely solely on onAuthStateChange and check the event type to initialize loading state only on INITIAL_SESSION:

🔧 Proposed fix
  useEffect(() => {
-   client.auth.getSession().then(({ data: { session } }) => {
-     setSession(session);
-     setUser(session?.user ?? null);
-     setIsLoading(false);
-   });
-
    const {
      data: { subscription },
-   } = client.auth.onAuthStateChange((_event, session) => {
+   } = client.auth.onAuthStateChange((event, session) => {
      setSession(session);
      setUser(session?.user ?? null);
+     if (event === 'INITIAL_SESSION') {
+       setIsLoading(false);
+     }
    });

    return () => subscription.unsubscribe();
  }, [client]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/SupabaseProvider.tsx` around lines 20 - 36, Remove the redundant
client.auth.getSession() call and instead rely only on
client.auth.onAuthStateChange within the useEffect; inside the onAuthStateChange
callback check the event parameter for "INITIAL_SESSION" to perform the initial
setSession/setUser/setIsLoading(false) and for other events only update
session/user (but not setIsLoading), and keep returning () =>
subscription.unsubscribe() to clean up; update references in the effect to use
the existing setSession, setUser, setIsLoading and the subscription variable
from client.auth.onAuthStateChange.

Comment thread lib/supabase/SupabaseProvider.tsx Outdated
Comment on lines +21 to +25
client.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setIsLoading(false);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for getSession().

The getSession() call can return an error (e.g., network failure, invalid refresh token), but it's silently ignored. Consider handling the error case to at least log it or surface it in the context for debugging.

🛡️ Proposed fix to handle errors
-   client.auth.getSession().then(({ data: { session } }) => {
+   client.auth.getSession().then(({ data: { session }, error }) => {
+     if (error) {
+       console.error("Failed to get session:", error.message);
+     }
      setSession(session);
      setUser(session?.user ?? null);
      setIsLoading(false);
    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
client.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setIsLoading(false);
});
client.auth.getSession().then(({ data: { session }, error }) => {
if (error) {
console.error("Failed to get session:", error.message);
}
setSession(session);
setUser(session?.user ?? null);
setIsLoading(false);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/SupabaseProvider.tsx` around lines 21 - 25, The
client.auth.getSession() promise call lacks error handling; wrap the call in a
try/catch or attach a .catch handler around client.auth.getSession() in
SupabaseProvider.tsx (the block that calls setSession, setUser, setIsLoading) to
handle failures: on error log the error (or surface it via context/state),
setUser(null)/setSession(null) if appropriate, and ensure setIsLoading(false)
runs in the error path so loading state is cleared; reference the
client.auth.getSession() invocation and the setSession/setUser/setIsLoading
calls when making this change.

Comment thread lib/supabase/UserMenu.tsx Outdated
<DropdownMenuTrigger asChild>
<Button variant="tertiary" forcedColorScheme="dark" className="mr-4 px-3 h-[32px]">
<UserIcon className="h-4 w-4 mr-1" />
<span className="text-sm max-sm:hidden">{compact ? user.email?.split("@")[0] : user.email}</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle case where user.email is undefined.

If user.email is undefined, compact ? user.email?.split("@")[0] : user.email will render undefined as text. Consider providing a fallback:

-<span className="text-sm max-sm:hidden">{compact ? user.email?.split("@")[0] : user.email}</span>
+<span className="text-sm max-sm:hidden">{compact ? user.email?.split("@")[0] : user.email ?? "User"}</span>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<span className="text-sm max-sm:hidden">{compact ? user.email?.split("@")[0] : user.email}</span>
<span className="text-sm max-sm:hidden">{compact ? user.email?.split("@")[0] : user.email ?? "User"}</span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/UserMenu.tsx` at line 39, The span currently renders undefined
when user.email is missing; update the expression in the UserMenu component (the
span that shows email) to use a safe fallback before splitting — e.g., derive a
local email string like const email = user.email ?? '' and then render compact ?
email.split("@")[0] : email (or render a visible fallback like '—'/'Unknown' if
you prefer) so splitting never receives undefined and the UI doesn't show
"undefined".

Comment on lines +40 to +65
useEffect(() => {
if (!user) return;

let cancelled = false;

let query = client.from("user_data").select("value").eq("user_id", user.id).eq("key", key);

if (effectiveAppId) {
query = query.eq("app_id", effectiveAppId);
} else {
query = query.is("app_id", null);
}

query.maybeSingle().then(({ data: row, error: queryError }) => {
if (cancelled) return;
setResult({
data: queryError ? null : ((row?.value as T) ?? null),
error: queryError ? new Error(queryError.message) : null,
fetchKey,
});
});

return () => {
cancelled = true;
};
}, [client, user, key, effectiveAppId, fetchKey]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

State not cleared when user logs out.

When user transitions from non-null to null, the effect returns early without clearing result.data. If the component remains mounted, stale data from the previous user persists in state until another user logs in. Consider resetting state when user becomes null.

🛡️ Proposed fix to clear state on logout
  useEffect(() => {
-   if (!user) return;
+   if (!user) {
+     setResult({ data: null, error: null, fetchKey: null });
+     return;
+   }

    let cancelled = false;
    // ... rest of effect
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (!user) return;
let cancelled = false;
let query = client.from("user_data").select("value").eq("user_id", user.id).eq("key", key);
if (effectiveAppId) {
query = query.eq("app_id", effectiveAppId);
} else {
query = query.is("app_id", null);
}
query.maybeSingle().then(({ data: row, error: queryError }) => {
if (cancelled) return;
setResult({
data: queryError ? null : ((row?.value as T) ?? null),
error: queryError ? new Error(queryError.message) : null,
fetchKey,
});
});
return () => {
cancelled = true;
};
}, [client, user, key, effectiveAppId, fetchKey]);
useEffect(() => {
if (!user) {
setResult({ data: null, error: null, fetchKey: null });
return;
}
let cancelled = false;
let query = client.from("user_data").select("value").eq("user_id", user.id).eq("key", key);
if (effectiveAppId) {
query = query.eq("app_id", effectiveAppId);
} else {
query = query.is("app_id", null);
}
query.maybeSingle().then(({ data: row, error: queryError }) => {
if (cancelled) return;
setResult({
data: queryError ? null : ((row?.value as T) ?? null),
error: queryError ? new Error(queryError.message) : null,
fetchKey,
});
});
return () => {
cancelled = true;
};
}, [client, user, key, effectiveAppId, fetchKey]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/useUserData.ts` around lines 40 - 65, When user becomes null the
useEffect currently returns early and leaves prior result.data intact; update
the effect in useUserData.ts so that when user is falsy you immediately clear
state by calling setResult({ data: null, error: null, fetchKey }) (or
equivalent) before returning. Modify the effect that references client, user,
key, effectiveAppId, fetchKey (inside the useEffect block that builds query and
calls query.maybeSingle) to handle the user === null branch by resetting result
via setResult so stale data is not shown after logout.

Comment on lines +71 to +73
const { error: upsertError } = await client
.from("user_data")
.upsert({ user_id: user.id, app_id: effectiveAppId, key, value }, { onConflict: "user_id,app_id,key" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the documented table schema in MDX/README for how the unique constraint is defined
rg -n "user_data|unique|constraint" --type md -C 3

Repository: FluffyLabs/shared-ui

Length of output: 1020


Upsert with nullable app_id will insert duplicates instead of updating shared data.

The documented schema (README.md) defines app_id as nullable and uses a standard unique constraint on (user_id, app_id, key). However, PostgreSQL's unique constraints treat NULL as distinct from NULL, so when effectiveAppId is null (for shared data), repeated save() calls will insert duplicate rows instead of updating the existing one.

Fix this by one of:

  • Define a partial unique index: unique (user_id, app_id, key) where app_id is not null (for nullable app_id)
  • Use a sentinel value (e.g., empty string "") instead of null for shared data, and add the corresponding index
  • If shared data should use a different constraint strategy, restructure the data model to separate app-specific and shared data
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/supabase/useUserData.ts` around lines 71 - 73, The upsert uses
effectiveAppId which can be null so PostgreSQL treats NULLs as distinct and
inserts duplicates; update the logic in useUserData.ts (the save/upsert path
where client.from("user_data").upsert(...) is called) to normalize shared/null
app IDs to a deterministic sentinel (e.g., empty string "") before calling
upsert (i.e., replace effectiveAppId === null with the sentinel) so the unique
constraint on (user_id, app_id, key) works as intended, or alternatively
implement the partial unique index approach in DB migration if you prefer not to
use a sentinel—pick one strategy and apply it consistently where effectiveAppId
is used.

- SupabaseProvider: remove redundant getSession() call, use only
  onAuthStateChange with INITIAL_SESSION event
- AuthFlow: detect duplicate email signups via empty identities array
  (Supabase returns fake user to prevent email enumeration)
- UserMenu: add "User" fallback when user.email is undefined
- useUserData: use empty string instead of NULL for shared data app_id
  to fix PostgreSQL unique constraint (NULL != NULL breaks upsert)
- Update SQL docs to use `app_id text not null default ''`

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@tomusdrw
tomusdrw merged commit 8216327 into main Mar 25, 2026
6 checks passed
@tomusdrw
tomusdrw deleted the feat/supabase-auth branch March 25, 2026 22:49
@github-actions github-actions Bot mentioned this pull request Mar 31, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Apr 11, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant