feat: add Supabase auth integration#72
Conversation
… 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>
✅ Deploy Preview for fluffy-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis 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 Changes
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)
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
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
- 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>
There was a problem hiding this comment.
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.0is 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.0or^2.100.0to 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
WithClassNamestory inAuthFlow.stories.tsxincludes aparameters.docs.description.storyproperty, 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-updateupdated_at.The
updated_atcolumn 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_atwill always reflect the initialcreated_atvalue.🤖 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 outsideuseMemo.Creating
createClientinsideuseMemomeans a new client instance is created wheneveruser,isLoading, orappIdchanges. 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 assertionrow?.value as Tlacks 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 inUseUserDataOptions.🤖 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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (19)
README.mdlib/supabase/AuthFlow.stories.tsxlib/supabase/AuthFlow.tsxlib/supabase/Settings.stories.tsxlib/supabase/Settings.tsxlib/supabase/StorybookProvider.tsxlib/supabase/Supabase.mdxlib/supabase/SupabaseProvider.tsxlib/supabase/UserMenu.stories.tsxlib/supabase/UserMenu.tsxlib/supabase/context.tslib/supabase/index.tslib/supabase/types.tslib/supabase/useSession.tslib/supabase/useSignOut.tslib/supabase/useUser.tslib/supabase/useUserData.tspackage.jsonvite.config.ts
| const { error } = await client.auth.signUp({ email, password }); | ||
| if (error) throw error; | ||
| setRegistered(true); |
There was a problem hiding this comment.
🧩 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:
- 1: https://supabase.com/docs/reference/javascript/auth-signup
- 2: https://supabase.io/docs/reference/javascript/auth-updateuser
- 3: https://supabase.com/docs/guides/auth/auth-email
- 4: https://stackoverflow.com/questions/73802604/how-to-check-if-user-already-exists-in-supabase
- 5: https://stackoverflow.com/questions/76967722/handling-sign-up-with-existing-email-in-supabase
- 6: https://stackoverflow.com/questions/74924142/supabase-auth-signup-doesnt-return-data-only-shows-error-in-response
- 7: https://stackoverflow.com/questions/78118540/supabase-doesnt-return-error-when-a-user-tries-to-signup-with-an-email-that-alr
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.
| 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]); |
There was a problem hiding this comment.
🧩 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:
- 1: https://supabase.com/docs/reference/javascript/auth-onauthstatechange
- 2: https://supabase.com/docs/reference/javascript/auth-reauthentication
- 3: https://supabase.com/docs/reference/javascript/auth-getsession
🏁 Script executed:
cat -n lib/supabase/SupabaseProvider.tsx | head -50Repository: 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.
| client.auth.getSession().then(({ data: { session } }) => { | ||
| setSession(session); | ||
| setUser(session?.user ?? null); | ||
| setIsLoading(false); | ||
| }); |
There was a problem hiding this comment.
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.
| 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.
| <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> |
There was a problem hiding this comment.
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.
| <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".
| 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]); |
There was a problem hiding this comment.
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.
| 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.
| const { error: upsertError } = await client | ||
| .from("user_data") | ||
| .upsert({ user_id: user.id, app_id: effectiveAppId, key, value }, { onConflict: "user_id,app_id,key" }); |
There was a problem hiding this comment.
🧩 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 3Repository: 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 ofnullfor 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>
Summary
@fluffylabs/shared-ui/supabasesubpath with auth components powered by SupabaseappIdfor shared vs per-app datacompactvariantToggleDarkModeuseUserDatasupporting both shared and app-scoped dataConfiguration required
Consuming apps need to:
npm install @supabase/supabase-jsuser_datatable with RLS in Supabase (SQL in docs)<SupabaseProvider supabaseUrl="..." supabaseAnonKey="..." appId="...">Test plan
npm run build:lib)🤖 Generated with Claude Code