wacrm/src/hooks/use-auth.tsx
Arnas Donauskas 738db87847
fix(auth): load account context without an embedded FK join
getCurrentAccount() and the client AuthProvider resolved the caller's
account through a PostgREST embedded join (`account:accounts!inner(...)`)
on profiles. That embed requires PostgREST to have the
profiles.account_id -> accounts.id relationship in its schema cache.
When the cache is stale -- a common Supabase state right after a
migration adds the FK, or when migrations are applied out of band -- the
embed fails hard with PGRST200 and the whole account context collapses.

It is the only query shape in the app that depends on relationship
inference (everything else reads accounts with plain `.eq('id', ...)`
filters), which is why the dashboard kept working while Settings ->
API keys / Team members returned 403 "Could not load account context"
and the Overview's cards hung loading (issue #294).

Replace the embed with a plain account-by-id lookup in both the server
helper and the client AuthProvider. Same accounts RLS, no relationship
inference, graceful degradation. The client now also keeps account_id /
account_role even if the account-name lookup itself fails. Adds a
regression test for getCurrentAccount.

Fixes #294

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:34:26 +03:00

376 lines
12 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
useMemo,
type ReactNode,
} from "react";
import { createClient } from "@/lib/supabase/client";
import type { User } from "@supabase/supabase-js";
import { DEFAULT_CURRENCY } from "@/lib/currency";
import {
canEditSettings as canEditSettingsFor,
canManageMembers as canManageMembersFor,
canSendMessages as canSendMessagesFor,
isAccountRole,
type AccountRole,
} from "@/lib/auth/roles";
interface Profile {
id: string;
full_name: string | null;
email: string;
avatar_url: string | null;
role: string | null;
/**
* Opted-in beta feature keys for this account. No current feature
* reads this — Flows was the last user and went to soft-GA in PR
* #134 — but the column survives for future beta gates.
*/
beta_features: string[];
account_id: string | null;
account_role: AccountRole | null;
}
interface AccountSummary {
id: string;
name: string;
/** Default deal currency (ISO-4217). NOT NULL DEFAULT 'USD' in the
* DB (migration 021); narrowed to DEFAULT_CURRENCY when absent. */
default_currency: string;
}
interface AuthContextValue {
user: User | null;
profile: Profile | null;
/**
* Session-level loading. Flips to false as soon as we know whether
* a user is signed in, *without* waiting for the profile row. Use
* this for chrome (sidebar / header) that can render with just the
* user object.
*/
loading: boolean;
/**
* Profile-row loading. Stays true until `fetchProfile` settles
* (success, missing row, or error). Code that branches on
* `profile.beta_features` MUST gate on this — otherwise it sees the
* `{ loading: false, profile: null }` window during initial load
* and may take the "not opted in" branch incorrectly.
*/
profileLoading: boolean;
signOut: () => Promise<void>;
/** Re-fetch the current user's profile row — call after a save from
* the settings form so header/sidebar reflect the change without a
* full page reload. */
refreshProfile: () => Promise<void>;
// ----------------------------------------------------------
// Account-scoped context (added by the account-sharing series)
//
// All of these are nullable until `profileLoading` is false.
// After the profile resolves they're guaranteed to be set,
// because migration 017 made `account_id` / `account_role`
// NOT NULL on `profiles`.
// ----------------------------------------------------------
/** Account id the current user belongs to. Null while loading. */
accountId: string | null;
/** Role within that account. Null while loading. */
accountRole: AccountRole | null;
/** Lightweight account meta — id + name + default_currency. Null while loading. */
account: AccountSummary | null;
/** Account default deal currency. Falls back to DEFAULT_CURRENCY
* while loading or when no account is resolved, so callers can use
* it unconditionally. */
defaultCurrency: string;
/** True if `accountRole === 'owner'`. */
isOwner: boolean;
/** True if `accountRole === 'admin'` (does NOT include owner — use canManageMembers for "admin or above"). */
isAdmin: boolean;
/** True if `accountRole === 'agent'`. */
isAgent: boolean;
/** True if `accountRole === 'viewer'`. */
isViewer: boolean;
/** True if the caller can manage members (admin+). */
canManageMembers: boolean;
/** True if the caller can edit account-wide settings (admin+). */
canEditSettings: boolean;
/** True if the caller can send messages and edit operational data (agent+). */
canSendMessages: boolean;
}
const AuthContext = createContext<AuthContextValue | null>(null);
/**
* AuthProvider — wrap this around the dashboard layout.
* Makes ONE getSession() call for the whole tree instead of one per
* component, avoiding internal lock contention in the Supabase client.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [account, setAccount] = useState<AccountSummary | null>(null);
const [loading, setLoading] = useState(true);
// Tracked separately from `loading`. The session settles fast (one
// local cookie read); the profile fetch crosses the network and
// settles later. Callers that gate on `profile.*` need to know which
// window they're in — see the type doc above.
const [profileLoading, setProfileLoading] = useState(true);
// Shared across init, auth-state-change listener, and the exposed
// refreshProfile() callback. Reads the current session's user id and
// pulls the matching profile row along with its account summary.
const fetchProfile = useCallback(async (userId: string) => {
const supabase = createClient();
setProfileLoading(true);
try {
const { data, error } = await supabase
.from("profiles")
.select(
"id, full_name, email, avatar_url, role, beta_features, account_id, account_role",
)
.eq("user_id", userId)
.maybeSingle();
if (error) {
console.error("[AuthProvider] fetchProfile error:", {
message: error.message,
details: error.details,
hint: error.hint,
code: error.code,
});
return;
}
if (data) {
// Load the account with a plain lookup by id instead of an
// embedded FK join. The embed (`account:accounts!inner(...)`)
// forces PostgREST to resolve the profiles.account_id →
// accounts.id relationship from its schema cache; a stale cache
// (common right after a migration adds the FK) makes it fail
// hard with PGRST200 and blanks the whole profile — the user
// then loses account context everywhere (issue #294). A point
// lookup by id needs no relationship inference, so the profile
// (with account_id / account_role) still resolves even if the
// account name lookup itself can't.
let accountRow: AccountSummary | null = null;
if (data.account_id) {
const { data: account, error: accountErr } = await supabase
.from("accounts")
// default_currency added in migration 021; narrowed to the
// USD fallback below for older schemas where it reads null.
.select("id, name, default_currency")
.eq("id", data.account_id)
.maybeSingle();
if (accountErr) {
console.error("[AuthProvider] fetchAccount error:", {
message: accountErr.message,
details: accountErr.details,
hint: accountErr.hint,
code: accountErr.code,
});
} else if (account) {
accountRow = {
id: account.id,
name: account.name,
default_currency: account.default_currency ?? DEFAULT_CURRENCY,
};
}
}
// Narrow the DB enum into our AccountRole union. The DB
// constraint should make this unconditional, but a future
// migration that broadens the enum without updating TS would
// otherwise crash here — fall back to null and let UI gates
// treat the caller as least-privileged.
const accountRole = isAccountRole(data.account_role)
? data.account_role
: null;
setProfile({
id: data.id,
full_name: data.full_name,
email: data.email,
avatar_url: data.avatar_url,
role: data.role,
// `beta_features` is `NOT NULL DEFAULT ARRAY[]` in the DB, but
// narrow defensively in case the column hasn't been migrated yet
// (older deployments running 011 lazily) — `null` reads as no
// opt-ins, which is the safe default for any future beta gate.
beta_features: data.beta_features ?? [],
account_id: data.account_id ?? null,
account_role: accountRole,
});
setAccount(accountRow);
}
} catch (err) {
console.error("[AuthProvider] fetchProfile threw:", err);
} finally {
setProfileLoading(false);
}
}, []);
useEffect(() => {
const supabase = createClient();
let mounted = true;
const safetyTimer = setTimeout(() => {
if (mounted) {
console.warn("[AuthProvider] getSession() timed out after 3s");
setLoading(false);
setProfileLoading(false);
}
}, 3000);
const init = async () => {
try {
const {
data: { session },
error,
} = await supabase.auth.getSession();
if (error) console.error("[AuthProvider] getSession error:", error.message);
if (!mounted) return;
const currentUser = session?.user ?? null;
setUser(currentUser);
if (currentUser) {
// Don't block session loading on profile fetch — chrome
// (header, sidebar) can render from the user object alone,
// profile enriches async. Callers that need to branch on
// profile data gate on `profileLoading` instead.
fetchProfile(currentUser.id);
} else {
// No user → no profile to load. Flip profileLoading off so
// pages that gate on it don't wait forever on the logged-out
// path (the route guard or redirect should fire instead).
setProfileLoading(false);
}
} catch (err) {
console.error("[AuthProvider] init threw:", err);
} finally {
if (mounted) setLoading(false);
clearTimeout(safetyTimer);
}
};
init();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return;
const currentUser = session?.user ?? null;
setUser(currentUser);
if (currentUser) {
fetchProfile(currentUser.id);
} else {
setProfile(null);
setAccount(null);
setProfileLoading(false);
}
setLoading(false);
});
return () => {
mounted = false;
clearTimeout(safetyTimer);
subscription.unsubscribe();
};
}, [fetchProfile]);
const signOut = useCallback(async () => {
const supabase = createClient();
await supabase.auth.signOut();
setUser(null);
setProfile(null);
setAccount(null);
window.location.href = "/login";
}, []);
const refreshProfile = useCallback(async () => {
if (!user?.id) return;
await fetchProfile(user.id);
}, [user?.id, fetchProfile]);
// Derive the role booleans once per profile change rather than on
// every consumer render. Cheap regardless, but the memo also gives
// each derived value a stable identity for React.memo / useEffect
// dependencies downstream.
const derived = useMemo(() => {
const role = profile?.account_role ?? null;
return {
accountRole: role,
accountId: profile?.account_id ?? null,
isOwner: role === "owner",
isAdmin: role === "admin",
isAgent: role === "agent",
isViewer: role === "viewer",
canManageMembers: role ? canManageMembersFor(role) : false,
canEditSettings: role ? canEditSettingsFor(role) : false,
canSendMessages: role ? canSendMessagesFor(role) : false,
};
}, [profile?.account_role, profile?.account_id]);
return (
<AuthContext.Provider
value={{
user,
profile,
loading,
profileLoading,
signOut,
refreshProfile,
account,
defaultCurrency: account?.default_currency ?? DEFAULT_CURRENCY,
...derived,
}}
>
{children}
</AuthContext.Provider>
);
}
/**
* useAuth — read the shared auth state from context.
* Must be used inside an <AuthProvider>.
*/
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
// Fallback for components rendered outside the provider (shouldn't
// happen in normal flow, but don't crash the page). Account state
// collapses to least-privileged null — every `canX` boolean is
// false so UI gates fail closed.
return {
user: null,
profile: null,
loading: false,
profileLoading: false,
signOut: async () => {
window.location.href = "/login";
},
refreshProfile: async () => {},
account: null,
defaultCurrency: DEFAULT_CURRENCY,
accountId: null,
accountRole: null,
isOwner: false,
isAdmin: false,
isAgent: false,
isViewer: false,
canManageMembers: false,
canEditSettings: false,
canSendMessages: false,
};
}
return ctx;
}