UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

430 lines (426 loc) 14.1 kB
'use client'; import { ConvexProviderWithAuth, useConvexAuth } from "convex/react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createAtomStore } from "jotai-x"; import { jsx, jsxs } from "react/jsx-runtime"; //#region src/crpc/error.ts /** * Client-side CRPC error. * Mirrors backend CRPCError pattern with typed error codes. */ var CRPCClientError = class extends Error { name = "CRPCClientError"; code; functionName; constructor(opts) { super(opts.message ?? `${opts.code}: ${opts.functionName}`); this.code = opts.code; this.functionName = opts.functionName; } }; /** Type guard for CRPCClientError */ const isCRPCClientError = (error) => error instanceof CRPCClientError; /** Default unauthorized detection - checks UNAUTHORIZED code */ const defaultIsUnauthorized = (error) => { if (!error || typeof error !== "object") return false; if ("data" in error) { const data = error.data; if (data && typeof data === "object" && "code" in data) return data.code === "UNAUTHORIZED"; } if ("code" in error) return error.code === "UNAUTHORIZED"; return false; }; //#endregion //#region src/react/auth-session-fallback.ts const SESSION_TOKEN_FALLBACK_KEY = "kitcn.auth.session-token"; const SESSION_DATA_FALLBACK_KEY = "kitcn.auth.session-data"; const getSessionStorage = () => { if (typeof window === "undefined") return null; try { return window.sessionStorage; } catch { return null; } }; const readAuthSessionFallbackToken = () => { const storage = getSessionStorage(); if (!storage) return null; const token = storage.getItem(SESSION_TOKEN_FALLBACK_KEY); return token && token.length > 0 ? token : null; }; const writeAuthSessionFallbackToken = (token) => { const storage = getSessionStorage(); if (!storage) return; if (token && token.length > 0) { storage.setItem(SESSION_TOKEN_FALLBACK_KEY, token); return; } storage.removeItem(SESSION_TOKEN_FALLBACK_KEY); }; const readAuthSessionFallbackData = () => { const storage = getSessionStorage(); if (!storage) return null; const value = storage.getItem(SESSION_DATA_FALLBACK_KEY); if (!value) return null; try { return JSON.parse(value); } catch { return null; } }; const writeAuthSessionFallbackData = (data) => { const storage = getSessionStorage(); if (!storage) return; if (data === null || data === void 0) { storage.removeItem(SESSION_DATA_FALLBACK_KEY); return; } storage.setItem(SESSION_DATA_FALLBACK_KEY, JSON.stringify(data)); }; const clearAuthSessionFallback = () => { writeAuthSessionFallbackToken(null); writeAuthSessionFallbackData(null); }; //#endregion //#region src/react/auth-store.tsx /** * Auth Store - Generic auth state management with jotai-x * * Provides token storage and auth callback configuration. * App configures handlers, lib hooks consume state. */ const FetchAccessTokenContext = createContext(null); /** Get fetchAccessToken from context (available immediately, no race condition) */ const useFetchAccessToken = () => useContext(FetchAccessTokenContext); /** * Context that holds auth result from ConvexAuthBridge. * Allows @convex-dev/auth users to use skipUnauth queries without better-auth. */ const ConvexAuthBridgeContext = createContext(null); /** Get auth from bridge context (null if no bridge configured) */ const useConvexAuthBridge = () => useContext(ConvexAuthBridgeContext); var ConvexAuthRecoveryError = class extends Error { code; constructor(code, message) { super(message); this.name = "ConvexAuthRecoveryError"; this.code = code; } }; const ConvexAuthRecoveryContext = createContext(null); /** Imperatively rebind Convex auth and wait for backend confirmation. */ function useConvexAuthRecovery() { const recovery = useContext(ConvexAuthRecoveryContext); if (!recovery) throw new Error("useConvexAuthRecovery must be used inside ConvexProviderWithAuth"); return recovery; } const AUTH_RECOVERY_TIMEOUT_MS = 1e4; function ConvexAuthRecoverySync({ authProviderAuthenticated, authProviderLoading, onChange }) { const auth = useConvexAuth(); useEffect(() => { onChange({ authProviderAuthenticated, authProviderLoading, isAuthenticated: auth.isAuthenticated, isLoading: auth.isLoading }); }, [ auth.isAuthenticated, auth.isLoading, authProviderAuthenticated, authProviderLoading, onChange ]); return null; } function ConvexAuthBinding({ bindingVersion, children, client, onAuthSnapshot, onBindingFetch, useAuth }) { const { fetchAccessToken: fetchAccessTokenFromAuth, isAuthenticated, isLoading } = useAuth(); const fetchAccessToken = useCallback((args) => { onBindingFetch(bindingVersion); return fetchAccessTokenFromAuth(args); }, [ bindingVersion, fetchAccessTokenFromAuth, onBindingFetch ]); const boundAuth = useMemo(() => ({ fetchAccessToken, isAuthenticated, isLoading }), [ fetchAccessToken, isAuthenticated, isLoading ]); return /* @__PURE__ */ jsxs(ConvexProviderWithAuth, { client, useAuth: useCallback(() => boundAuth, [boundAuth]), children: [/* @__PURE__ */ jsx(ConvexAuthRecoverySync, { authProviderAuthenticated: isAuthenticated, authProviderLoading: isLoading, onChange: onAuthSnapshot }), /* @__PURE__ */ jsx(ConvexAuthBridge, { children })] }); } const AUTH_SESSION_SYNC_GRACE_MS = 1e4; const isSessionSyncGraceActive = (sessionSyncGraceUntil) => typeof sessionSyncGraceUntil === "number" && sessionSyncGraceUntil > Date.now(); /** Decode JWT expiration (ms timestamp) from token */ function decodeJwtExp(token) { try { const payload = JSON.parse(atob(token.split(".")[1])); return payload.exp ? payload.exp * 1e3 : null; } catch { return null; } } const { AuthProvider, useAuthStore, useAuthState, useAuthValue } = createAtomStore({ onMutationUnauthorized: () => { throw new CRPCClientError({ code: "UNAUTHORIZED", functionName: "mutation" }); }, onQueryUnauthorized: () => {}, isUnauthorized: defaultIsUnauthorized, token: null, expiresAt: null, isLoading: true, isAuthenticated: false, sessionSyncGraceUntil: null }, { name: "auth", suppressWarnings: true }); /** * Safe wrapper around useConvexAuth that doesn't throw when used outside auth provider. * Returns { isAuthenticated: false, isLoading: false } when no auth provider. * * Supports both: * - better-auth users (via AuthProvider) * - @convex-dev/auth users (via ConvexAuthBridge) */ function useSafeConvexAuth() { const authStore = useAuthStore(); const bridgeAuth = useConvexAuthBridge(); if (authStore.store) return { isAuthenticated: useAuthValue("isAuthenticated"), isLoading: useAuthValue("isLoading") }; if (bridgeAuth !== null) return bridgeAuth; return { isAuthenticated: false, isLoading: false }; } /** * Internal bridge component. Use `ConvexProviderWithAuth` instead. * @internal */ function ConvexAuthBridge({ children }) { const auth = useConvexAuth(); return /* @__PURE__ */ jsx(ConvexAuthBridgeContext.Provider, { value: auth, children }); } /** * Convex provider with auth bridge for @convex-dev/auth users. * Automatically wraps children with ConvexAuthBridge. * * @example * ```tsx * import { ConvexProviderWithAuth } from 'kitcn/react'; * * <ConvexProviderWithAuth client={convex} useAuth={useAuthFromConvexDev}> * <App /> * </ConvexProviderWithAuth> * ``` */ function ConvexProviderWithAuth$1({ children, client, useAuth }) { const [bindingVersion, setBindingVersion] = useState(0); const [error, setError] = useState(null); const [status, setStatus] = useState("idle"); const bindingVersionRef = useRef(0); const pendingRef = useRef(null); const authSnapshotRef = useRef({ authProviderAuthenticated: false, authProviderLoading: true, isAuthenticated: false, isLoading: true }); const settleRecovery = useCallback((recoveryError) => { const pending = pendingRef.current; if (!pending) return; clearTimeout(pending.timeoutId); pendingRef.current = null; if (recoveryError) { setError(recoveryError); setStatus("failed"); pending.reject(recoveryError); return; } setError(null); setStatus("idle"); pending.resolve(); }, []); const onAuthSnapshot = useCallback((snapshot) => { authSnapshotRef.current = snapshot; const pending = pendingRef.current; if (!pending) return; if (snapshot.authProviderLoading) { settleRecovery(new ConvexAuthRecoveryError("AUTH_PROVIDER_LOADING", "Cannot recover Convex auth while the auth provider is loading")); return; } if (!snapshot.authProviderAuthenticated) { settleRecovery(new ConvexAuthRecoveryError("AUTH_PROVIDER_UNAUTHENTICATED", "Cannot recover Convex auth without an authenticated provider session")); return; } if (!pending.isBound || snapshot.isLoading) return; if (snapshot.isAuthenticated) { settleRecovery(null); return; } settleRecovery(new ConvexAuthRecoveryError("AUTH_RECOVERY_FAILED", "Convex rejected the recovered authentication binding")); }, [settleRecovery]); const onBindingFetch = useCallback((activeBindingVersion) => { const pending = pendingRef.current; if (pending?.bindingVersion === activeBindingVersion) pending.isBound = true; }, []); const recover = useCallback((options = {}) => { const pending = pendingRef.current; if (pending) return pending.promise; const authSnapshot = authSnapshotRef.current; if (authSnapshot.authProviderLoading) { const recoveryError = new ConvexAuthRecoveryError("AUTH_PROVIDER_LOADING", "Cannot recover Convex auth while the auth provider is loading"); setError(recoveryError); setStatus("failed"); return Promise.reject(recoveryError); } if (!authSnapshot.authProviderAuthenticated) { const recoveryError = new ConvexAuthRecoveryError("AUTH_PROVIDER_UNAUTHENTICATED", "Cannot recover Convex auth without an authenticated provider session"); setError(recoveryError); setStatus("failed"); return Promise.reject(recoveryError); } let resolve; let reject; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); const timeoutMs = options.timeoutMs ?? AUTH_RECOVERY_TIMEOUT_MS; const nextBindingVersion = bindingVersionRef.current + 1; const timeoutId = setTimeout(() => { settleRecovery(new ConvexAuthRecoveryError("AUTH_RECOVERY_TIMEOUT", `Convex auth recovery timed out after ${timeoutMs}ms`)); }, timeoutMs); pendingRef.current = { bindingVersion: nextBindingVersion, isBound: false, promise, reject, resolve, timeoutId }; bindingVersionRef.current = nextBindingVersion; setError(null); setStatus("recovering"); setBindingVersion(nextBindingVersion); return promise; }, [settleRecovery]); useEffect(() => () => { const pending = pendingRef.current; if (!pending) return; clearTimeout(pending.timeoutId); pendingRef.current = null; pending.reject(new ConvexAuthRecoveryError("AUTH_RECOVERY_CANCELLED", "Convex auth recovery was cancelled because the provider unmounted")); }, []); const recovery = useMemo(() => ({ error, recover, status }), [ error, recover, status ]); return /* @__PURE__ */ jsx(ConvexAuthRecoveryContext.Provider, { value: recovery, children: /* @__PURE__ */ jsx(ConvexAuthBinding, { bindingVersion, client, onAuthSnapshot, onBindingFetch, useAuth, children }) }); } const useAuth = () => { const authStore = useAuthStore(); const bridgeAuth = useConvexAuthBridge(); if (authStore.store) { if (typeof window === "undefined") return { hasSession: !!authStore.get("token"), isAuthenticated: false, isLoading: true }; const token = useAuthValue("token"); const isAuthenticated = useAuthValue("isAuthenticated"); const isLoading = useAuthValue("isLoading"); return { hasSession: !!token, isAuthenticated, isLoading }; } if (bridgeAuth !== null) return { hasSession: false, isAuthenticated: bridgeAuth.isAuthenticated, isLoading: bridgeAuth.isLoading }; return { hasSession: false, isAuthenticated: false, isLoading: false }; }; /** Check if user maybe has auth (optimistic, has token) */ const useMaybeAuth = () => { const { hasSession } = useAuth(); return hasSession; }; /** Check if user is authenticated (server-verified) */ const useIsAuth = () => { const { isAuthenticated } = useAuth(); return isAuthenticated; }; const useAuthGuard = () => { const { isAuthenticated } = useSafeConvexAuth(); const onMutationUnauthorized = useAuthValue("onMutationUnauthorized"); return (callback) => { if (!isAuthenticated) { onMutationUnauthorized(); return true; } return callback ? void callback() : false; }; }; /** Render children only when maybe has auth (optimistic) */ function MaybeAuthenticated({ children }) { return useMaybeAuth() ? children : null; } /** Render children only when authenticated (server-verified) */ function Authenticated({ children }) { return useIsAuth() ? children : null; } /** Render children only when maybe not auth (optimistic) */ function MaybeUnauthenticated({ children }) { return useMaybeAuth() ? null : children; } /** Render children only when not authenticated (server-verified) */ function Unauthenticated({ children }) { const { isAuthenticated, isLoading } = useAuth(); return isLoading || isAuthenticated ? null : children; } //#endregion export { defaultIsUnauthorized as A, useSafeConvexAuth as C, writeAuthSessionFallbackData as D, readAuthSessionFallbackToken as E, writeAuthSessionFallbackToken as O, useMaybeAuth as S, readAuthSessionFallbackData as T, useAuthValue as _, ConvexAuthRecoveryError as a, useFetchAccessToken as b, MaybeAuthenticated as c, decodeJwtExp as d, isSessionSyncGraceActive as f, useAuthStore as g, useAuthState as h, ConvexAuthBridge as i, isCRPCClientError as j, CRPCClientError as k, MaybeUnauthenticated as l, useAuthGuard as m, AuthProvider as n, ConvexProviderWithAuth$1 as o, useAuth as p, Authenticated as r, FetchAccessTokenContext as s, AUTH_SESSION_SYNC_GRACE_MS as t, Unauthenticated as u, useConvexAuthBridge as v, clearAuthSessionFallback as w, useIsAuth as x, useConvexAuthRecovery as y };