diff --git a/apps/nextjs/app-router/src/app/client/page.tsx b/apps/nextjs/app-router/src/app/client/page.tsx index 84a00e1a..6d500c37 100644 --- a/apps/nextjs/app-router/src/app/client/page.tsx +++ b/apps/nextjs/app-router/src/app/client/page.tsx @@ -3,12 +3,13 @@ import Link from "next/link" import Image from "next/image" import { Button } from "@/components/ui/button" -import { useAuth } from "@aura-stack/next/client" +import { useSession, useAuthActions } from "@aura-stack/next/client" import { EditProfile } from "@/components/edit-profile" import type { SubmitEvent } from "react" export const AuthClientPage = () => { - const { session, status, isPending, signIn, signOut, signInCredentials, updateSession } = useAuth() + const { signIn, signInCredentials, updateSession, signOut, isPending } = useAuthActions() + const { session, status } = useSession() const isAuthenticated = status === "authenticated" const handleSignInCredentials = async (event: SubmitEvent) => { @@ -118,7 +119,7 @@ export const AuthClientPage = () => { variant="outline" disabled={isPending} key={provider} - onClick={() => signIn(provider.toLowerCase())} + onClick={async () => await signIn(provider.toLowerCase(), { redirect: true })} > Sign In with {provider} @@ -137,6 +138,7 @@ export const AuthClientPage = () => { type="text" id="username" name="username" + aria-label="Username" className="w-full h-9 mt-1 font-medium border border-input rounded-none bg-background hover:text-accent-foreground hover:bg-input/50 focus:outline-1" /> @@ -148,6 +150,7 @@ export const AuthClientPage = () => { type="password" id="password" name="password" + aria-label="Password" className="w-full h-9 mt-1 font-medium border border-input rounded-none bg-background hover:text-accent-foreground hover:bg-input/50 focus:outline-1" /> diff --git a/apps/nextjs/app-router/src/components/header.tsx b/apps/nextjs/app-router/src/components/header.tsx index 6b72e8f7..74fbd134 100644 --- a/apps/nextjs/app-router/src/components/header.tsx +++ b/apps/nextjs/app-router/src/components/header.tsx @@ -4,11 +4,12 @@ import Link from "next/link" import { useState } from "react" import { Menu, X } from "lucide-react" import { Button } from "@/components/ui/button" -import { useAuth } from "@aura-stack/next/client" +import { useAuthActions, useSession } from "@aura-stack/next/client" export const Header = () => { const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const { status, isPending, signOut, signIn } = useAuth() + const { isPending, signOut, signIn } = useAuthActions() + const { status } = useSession() const isAuthenticated = status === "authenticated" const handleSignOut = async () => { diff --git a/apps/nextjs/pages-router/src/components/header.tsx b/apps/nextjs/pages-router/src/components/header.tsx index c97d7127..2032e652 100644 --- a/apps/nextjs/pages-router/src/components/header.tsx +++ b/apps/nextjs/pages-router/src/components/header.tsx @@ -3,12 +3,13 @@ import { useRouter } from "next/router" import { useState } from "react" import { Menu, X } from "lucide-react" import { Button } from "@/components/ui/button" -import { useAuth } from "@aura-stack/next/client" +import { useAuthActions, useSession } from "@aura-stack/next/client" export const Header = () => { const router = useRouter() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const { status, isPending, signOut, signIn } = useAuth() + const { status } = useSession() + const { isPending, signOut, signIn } = useAuthActions() const isAuthenticated = status === "authenticated" const handleSignOut = async () => { diff --git a/apps/nextjs/pages-router/src/pages/client/index.tsx b/apps/nextjs/pages-router/src/pages/client/index.tsx index f6f2570c..52a8f66e 100644 --- a/apps/nextjs/pages-router/src/pages/client/index.tsx +++ b/apps/nextjs/pages-router/src/pages/client/index.tsx @@ -1,12 +1,13 @@ import Link from "next/link" import Image from "next/image" import { Button } from "@/components/ui/button" -import { useAuth } from "@aura-stack/next/client" +import { useAuthActions, useSession } from "@aura-stack/next/client" import { EditProfile } from "@/components/edit-profile" import type { SubmitEvent } from "react" export default function AuthClientPage() { - const { session, status, isPending, signIn, signOut, signInCredentials, updateSession } = useAuth() + const { session, status } = useSession() + const { isPending, signIn, signOut, signInCredentials, updateSession } = useAuthActions() const isAuthenticated = status === "authenticated" const handleSignInCredentials = async (event: SubmitEvent) => { @@ -20,6 +21,7 @@ export default function AuthClientPage() { username, password, }, + redirect: true, redirectTo: "/client", }) } diff --git a/apps/react-router/app/components/header.tsx b/apps/react-router/app/components/header.tsx index 6aa0a471..076467da 100644 --- a/apps/react-router/app/components/header.tsx +++ b/apps/react-router/app/components/header.tsx @@ -2,12 +2,13 @@ import { useState } from "react" import { Menu, X } from "lucide-react" import { Button } from "~/components/ui/button" import { Link, useRevalidator } from "react-router" -import { useAuth } from "@aura-stack/react-router/client" +import { useAuthActions, useSession } from "@aura-stack/react-router/client" export const Header = () => { const revalidator = useRevalidator() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const { status, isPending, signOut, signIn } = useAuth() + const { status } = useSession() + const { isPending, signOut, signIn } = useAuthActions() const isAuthenticated = status === "authenticated" const handleSignOut = async () => { diff --git a/apps/react-router/app/routes/client/index.tsx b/apps/react-router/app/routes/client/index.tsx index 61ae0b44..f1807e4c 100644 --- a/apps/react-router/app/routes/client/index.tsx +++ b/apps/react-router/app/routes/client/index.tsx @@ -1,12 +1,13 @@ import { Link, useRevalidator } from "react-router" import { Button } from "~/components/ui/button" -import { useAuth } from "@aura-stack/react-router/client" +import { useAuthActions, useSession } from "@aura-stack/react-router/client" import { EditProfile } from "~/components/edit-profile" import type { SubmitEvent } from "react" export const AuthClientPage = () => { const revalidator = useRevalidator() - const { session, status, isPending, signIn, signOut, signInCredentials, updateSession } = useAuth() + const { session, status } = useSession() + const { isPending, signIn, signOut, signInCredentials, updateSession } = useAuthActions() const isAuthenticated = status === "authenticated" const handleSignInCredentials = async (event: SubmitEvent) => { @@ -130,6 +131,7 @@ export const AuthClientPage = () => { type="text" id="username" name="username" + aria-label="Username" className="w-full h-9 mt-1 font-medium border border-input rounded-none bg-background hover:text-accent-foreground hover:bg-input/50 focus:outline-1" /> @@ -141,6 +143,7 @@ export const AuthClientPage = () => { type="password" id="password" name="password" + aria-label="Password" className="w-full h-9 mt-1 font-medium border border-input rounded-none bg-background hover:text-accent-foreground hover:bg-input/50 focus:outline-1" /> diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 08ce4fc0..87d082af 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -73,7 +73,7 @@ export const createAuthClient = (options: AuthC }, }) const json = await response.json() - if ((options?.redirect ?? true) && typeof window !== "undefined" && json?.signInURL) { + if (options?.redirect === true && typeof window !== "undefined" && json?.signInURL) { window.location.assign(json.signInURL) } return json as unknown as SignInReturn @@ -94,7 +94,7 @@ export const createAuthClient = (options: AuthC }, }) const json = await response.json() - if ((options?.redirect ?? true) && typeof window !== "undefined" && json?.redirectURL) { + if (options?.redirect === true && typeof window !== "undefined" && json?.redirectURL) { window.location.assign(json.redirectURL) } return json as unknown as SignInCredentialsReturn @@ -128,7 +128,7 @@ export const createAuthClient = (options: AuthC }, }) const json = await response.json() - if ((options.redirect ?? true) && typeof window !== "undefined" && json?.redirectURL) { + if (options?.redirect === true && typeof window !== "undefined" && json?.redirectURL) { window.location.assign(json.redirectURL) } return json as unknown as UpdateSessionReturn @@ -155,7 +155,7 @@ export const createAuthClient = (options: AuthC }, }) const json = await response.json() - if ((options?.redirect ?? true) && typeof window !== "undefined" && json?.redirectURL) { + if (options?.redirect === true && typeof window !== "undefined" && json?.redirectURL) { window.location.assign(json.redirectURL) } return json as unknown as SignOutReturn diff --git a/packages/core/test/client/client.test.ts b/packages/core/test/client/client.test.ts index 3e53f469..5fca2e0e 100644 --- a/packages/core/test/client/client.test.ts +++ b/packages/core/test/client/client.test.ts @@ -170,6 +170,29 @@ describe("createAuthClient", () => { expect(response).toEqual({ signInURL: "https://example.com/oauth" }) }) + test("signIn with redirect: false does not navigate", async () => { + vi.stubGlobal("window", { location: { assign: vi.fn() } }) + + const get = vi.fn().mockResolvedValue(createJSONResponse({ signInURL: "https://example.com/oauth" })) + + createClientMock.mockReturnValue({ + get, + post: vi.fn(), + }) + const client = createAuthClient({ baseURL: "https://example.com" }) + await client.signIn("github", { redirect: false }) + + expect(get).toHaveBeenCalledWith("/signIn/:oauth", { + params: { oauth: "github" }, + searchParams: { + // The redirect is set to false in the request to prevent automatic + // redirection by server response by 302 status code. + redirect: false, + }, + }) + expect(window.location.assign).not.toHaveBeenCalled() + }) + test("signInCredentials", async () => { const post = vi.fn().mockResolvedValue( createJSONResponse({ diff --git a/packages/next/src/client.ts b/packages/next/src/client.ts index f8e58611..e631e084 100644 --- a/packages/next/src/client.ts +++ b/packages/next/src/client.ts @@ -3,11 +3,12 @@ export { createAuthClient, AuthProvider, - useAuth, + useAuthActions, useSession, useSignIn, useSignInCredentials, useSignOut, + useUpdateSession, type AuthProviderProps, type AuthClientOptions, } from "@aura-stack/react" diff --git a/packages/react-router/package.json b/packages/react-router/package.json index fbf91cbf..3e5eaa6e 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -82,4 +82,4 @@ "react-router": ">=7.0.0" }, "packageManager": "pnpm@10.15.0" -} +} \ No newline at end of file diff --git a/packages/react-router/src/client.ts b/packages/react-router/src/client.ts index ce4d4879..4f294308 100644 --- a/packages/react-router/src/client.ts +++ b/packages/react-router/src/client.ts @@ -1,7 +1,7 @@ export { createAuthClient, AuthProvider, - useAuth, + useAuthActions, useSession, useSignIn, useSignInCredentials, diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 8603cd8b..723ed4a2 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Added support for multi-tab synchronization via `BroadcastChannel`, allowing sessions to stay synchronized across browser tabs during the auth flow. Additionally, added a centralized `useAuthActions` hook that re-exports all auth actions (`signIn`, `updateSession`, `signOut`, etc.). [#172](https://github.com/aura-stack-ts/auth/pull/172) + - Updated React context and hooks (`useSignInCredentials`, `useUpdateSession`, and related context actions) to align with the standardized core client API contracts, including the new object-based credentials/session payload shapes and redirect-driven refresh behavior, while simplifying React-side auth type definitions. [#146](https://github.com/aura-stack-ts/auth/pull/146) - Removed and cleaned up types and functions exported from the index `/` entry point to reduce import noise, and introduced `/identity`, `/crypto`, and `/shared` as direct entry points for specific utilities. [#141](https://github.com/aura-stack-ts/auth/pull/141) diff --git a/packages/react/package.json b/packages/react/package.json index f72d72f6..1ae44921 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -102,4 +102,4 @@ "@types/react": ">=19.0.0" }, "packageManager": "pnpm@10.15.0" -} +} \ No newline at end of file diff --git a/packages/react/src/@types/types.ts b/packages/react/src/@types/types.ts index 5cb23b92..29108760 100644 --- a/packages/react/src/@types/types.ts +++ b/packages/react/src/@types/types.ts @@ -13,26 +13,12 @@ import type { createAuthClient } from "@aura-stack/auth/client" export type AuthClientInstance = ReturnType> /** High-level UI state for whether a session is present, absent, or still being resolved. */ -export type AuthStatus = "authenticated" | "unauthenticated" | "loading" +export type AuthStatus = "authenticated" | "unauthenticated" | "pending" -/** - * Full auth surface exposed through a single React context so session state and - * mutations share one source of truth (no duplicate session fetches per subtree). - */ -export type AuthReactContextValue = { - /** Current session, `null` if unauthenticated, or `undefined` before the first load completes. */ - session: Session | null | undefined +export interface Context { + session: Session | null status: AuthStatus - /** True while a transition updates session state (e.g. after refresh or a non-redirect sign-in). */ - isPending: boolean - /** Bound auth HTTP client (same API as `createAuthClient`). */ client: AuthClientInstance - /** Re-fetches session from the server and updates context state. */ - refresh: () => Promise - signIn: AuthClientInstance["signIn"] - signInCredentials: AuthClientInstance["signInCredentials"] - signOut: AuthClientInstance["signOut"] - updateSession: AuthClientInstance["updateSession"] } /** Props for {@link AuthProvider}: supply the client and optional SSR session to avoid a flash of loading state. */ @@ -46,3 +32,8 @@ export type AuthProviderProps = { */ initialSession?: Session | null } + +/** + * Triggerable messages for cross-tab session synchronization via BroadcastChannel. + */ +export type BroadcastMessage = { type: "session:update"; payload: Session } | { type: "session:sync" } | { type: "session:clear" } diff --git a/packages/react/src/context.tsx b/packages/react/src/context.tsx index fb498133..95548f41 100644 --- a/packages/react/src/context.tsx +++ b/packages/react/src/context.tsx @@ -1,128 +1,97 @@ -import { createContext, useCallback, useEffect, useMemo, useState, useTransition } from "react" +import { createContext, useCallback, useEffect, useRef, useState } from "react" import type { Session, User } from "@aura-stack/auth" -import type { - LiteralUnion, - SignInOptions, - SignOutOptions, - BuiltInOAuthProvider, - SignInCredentialsOptions, - UpdateSessionOptions, -} from "@aura-stack/auth/types" -import type { AuthProviderProps, AuthReactContextValue } from "@/@types/types.ts" +import type { AuthClientInstance, AuthProviderProps, BroadcastMessage, Context } from "@/@types/types.ts" -/** - * React context for {@link AuthReactContextValue}. Use {@link AuthProvider} to supply a client and {@link useAuth} (or other hooks) to read it. - */ -export const AuthContext = createContext | null>(null) +export { AuthProviderProps } + +export const AuthContext = createContext(undefined) + +const BROADCAST_CHANNEL_NAME = "aura-auth" + +let _channel: BroadcastChannel | null = null + +const isSupportedBroadcastChannel = (): boolean => { + return typeof window !== "undefined" && "BroadcastChannel" in window +} -export type { AuthProviderProps } +const getBroadcastChannel = (): BroadcastChannel | null => { + if (!isSupportedBroadcastChannel()) return null + if (!_channel) _channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME) + return _channel +} + +export const broadcast = (message: BroadcastMessage): void => { + getBroadcastChannel()?.postMessage(message) +} /** - * Provides session state and auth actions for the tree, using the {@link AuthProviderProps.client} you pass in. - * Swap or recreate `client` when you need different configuration; when `client` changes, session is re-synced like on mount. + * Wrapper component that provides authentication context in client-side React applications. + * + * @param {AuthProviderProps} props The properties for the AuthProvider component + * @returns {JSX.Element} The AuthProvider component that wraps its children with authentication context + * @example + * const client = createAuthClient({ baseURL: "http://localhost:3000" }) + * + * + * + * */ export const AuthProvider = ({ + initialSession, children, client, - initialSession, }: AuthProviderProps) => { - const [session, setSession] = useState | null | undefined>(initialSession) - const [isPending, startTransition] = useTransition() + const clientRef = useRef>(client) + clientRef.current = client + + const [session, setSession] = useState | null>(() => { + if (initialSession !== undefined) { + return initialSession + } + return null + }) + const [status, setStatus] = useState(initialSession ? "authenticated" : "unauthenticated") - const status = useMemo((): AuthReactContextValue["status"] => { - if (session === undefined) return "loading" - return session ? "authenticated" : "unauthenticated" - }, [session]) + const refreshSession = useCallback(async (session: Session | null | undefined = undefined) => { + setStatus("pending") + try { + const next = session !== undefined ? session : ((await clientRef.current.getSession()) ?? null) + setSession(next as Session | null) + setStatus(next ? "authenticated" : "unauthenticated") + } catch { + setSession(null) + setStatus("unauthenticated") + } + }, []) - const refresh = useCallback(async () => { - startTransition(async () => { - const next = await client.getSession() - setSession(next) - }) - }, [client]) + useEffect(() => { + if (initialSession === undefined) { + refreshSession() + } + }, [initialSession, refreshSession]) - const signIn = useCallback( - async (oauth: LiteralUnion, options?: SignInOptions) => { - const result = await client.signIn(oauth, options) - if (!(options?.redirect ?? true)) { - await refresh() - } - return result - }, - [client, refresh] - ) + useEffect(() => { + if (!isSupportedBroadcastChannel()) return + const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME) - const signInCredentials = useCallback( - async (options: SignInCredentialsOptions) => { - const result = await client.signInCredentials(options) - if (!(options?.redirect ?? true)) { - await refresh() + const handleBroadcast = (event: MessageEvent) => { + if (event.data.type === "session:update") { + refreshSession(event.data.payload) } - return result - }, - [client, refresh] - ) - - const signOut = useCallback( - async (signOutOptions?: SignOutOptions) => { - const result = await client.signOut(signOutOptions) - if (!(signOutOptions?.redirect ?? true)) { - await refresh() + if (event.data.type === "session:sync") { + refreshSession() } - return result - }, - [client, refresh] - ) - - const updateSession = useCallback( - async (options: UpdateSessionOptions) => { - const result = await client.updateSession(options) - if (!(options?.redirect ?? true)) { - await refresh() + if (event.data.type === "session:clear") { + refreshSession(null) } - return result - }, - [client, refresh] - ) - - useEffect(() => { - if (initialSession !== undefined) { - startTransition(() => { - setSession(initialSession) - }) - return } - let cancelled = false - ;(async () => { - const next = await client.getSession() - if (!cancelled) { - startTransition(() => { - setSession(next) - }) - } - })() - + channel.addEventListener("message", handleBroadcast) return () => { - cancelled = true + channel.removeEventListener("message", handleBroadcast) + channel.close() } - }, [initialSession, client]) - - const value = useMemo( - (): AuthReactContextValue => - ({ - session, - status, - isPending, - client, - refresh, - signIn, - signInCredentials, - signOut, - updateSession, - }) as AuthReactContextValue, - [session, status, isPending, client, refresh, signIn, signInCredentials, signOut, updateSession] - ) + }, [refreshSession]) - return }>{children} + return {children} } diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index 5e3c1d92..5bd3ae69 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -1,77 +1,240 @@ -import { use, useCallback, useRef } from "react" -import { AuthContext } from "@/context.tsx" +"use client" +import { User } from "@aura-stack/auth" +import { use, useCallback, useTransition } from "react" +import { AuthContext, broadcast } from "@/context.tsx" import type { - LiteralUnion, BuiltInOAuthProvider, + LiteralUnion, + SignInCredentialsOptions, + SignInCredentialsReturn, SignInOptions, + SignInReturn, SignOutOptions, - User, - SignInCredentialsOptions, + SignOutReturn, UpdateSessionOptions, + UpdateSessionReturn, } from "@aura-stack/auth/types" -import type { AuthReactContextValue } from "@/@types/types.ts" +import type { Context } from "@/@types/types.ts" -export const useAuth = (): AuthReactContextValue => { +const useAssertContext = () => { const ctx = use(AuthContext) - if (!ctx) { - throw new Error("useAuth must be used within an AuthProvider.") + if (ctx === undefined) { + throw new Error("Auth hooks must be used within an .") } - return ctx as AuthReactContextValue + return ctx as Context +} + +const useAsyncAction = () => { + const [isPending, startTransition] = useTransition() + + const execute = useCallback((action: () => Promise): Promise => { + return new Promise((resolve, reject) => { + startTransition(async () => { + try { + const value = await action() + resolve(value) + } catch (error) { + reject(error) + } + }) + }) + }, []) + + return { execute, isPending } as const } +/** + * Gets the current authentication session and status. + * + * @returns An object containing the current session, status and a isPending + * @example + * const Page = () => { + * const { session, status, isPending } = useSession() + * if (isPending) { + * return
Loading...
+ * } + * return
{session ? `Hello, ${session.user.name}` : "Not signed in"}
+ * } + */ export const useSession = () => { - const { session, status } = useAuth() - return { session, status } + const { session, status } = useAssertContext() + return { session, status, isPending: status === "pending" } as const } /** - * OAuth sign-in. Pass default {@link SignInOptions} once; each call can still override - * `redirect`, `redirectTo`, etc. Call-time options win on conflict. + * Initiates the OAuth sign-in process to third-party providers. + * + * @returns An object containing the signIn function and a isPending state + * @example + * const Page = () => { + * const { signIn, isPending } = useSignIn() + * return ( + * + * ) + * } */ -export const useSignIn = (defaultOptions?: SignInOptions) => { - const { signIn } = useAuth() - const defaultsRef = useRef(defaultOptions) - defaultsRef.current = defaultOptions - return useCallback( - (oauth: LiteralUnion, signInOptions?: SignInOptions) => - signIn(oauth, { ...defaultsRef.current, ...signInOptions }), - [signIn] +export const useSignIn = () => { + const { client } = useAssertContext() + const { execute, isPending } = useAsyncAction() + + const signIn = useCallback( + ( + oauth: LiteralUnion, + options?: Options + ): Promise> => { + return execute(async () => { + const value = await client.signIn(oauth, options) + broadcast({ type: "session:sync" }) + return value + }) + }, + [client, execute] ) + + return { signIn, isPending } as const } /** - * Credentials sign-in. Default {@link SignInOptions} are merged with per-invocation options. + * Signs in a user using their credentials (e.g. username and password). + * + * @returns An object containing the signInCredentials function and a isPending state + * @example + * const Page = () => { + * const { signInCredentials, isPending } = useSignInCredentials() + * + * const handleSubmit = async (event: React.FormEvent) => { + * event.preventDefault() + * const formData = new FormData(event.currentTarget) + * const username = formData.get("username") as string + * const password = formData.get("password") as string + * await signInCredentials({ payload: { username, password }, redirectTo: "/dashboard" }) + * } + * return ( + *
+ * + * + * + *
+ * ) + * } */ -export const useSignInCredentials = (defaultOptions?: SignInOptions) => { - const { signInCredentials } = useAuth() - const defaultsRef = useRef(defaultOptions) - defaultsRef.current = defaultOptions - return useCallback( - (options: SignInCredentialsOptions) => signInCredentials({ ...defaultsRef.current, ...options }), - [signInCredentials] +export const useSignInCredentials = () => { + const { client } = useAssertContext() + const { execute, isPending } = useAsyncAction() + + const signInCredentials = useCallback( + (options: Options): Promise> => { + return execute(async () => { + const value = await client.signInCredentials(options) + broadcast({ type: "session:sync" }) + return value + }) + }, + [client, execute] ) + + return { signInCredentials, isPending } as const } /** - * Sign-out. Default {@link SignOutOptions} (`redirect`, `redirectTo`, …) merge with each call. + * Updates the current user's session. + * + * @returns An object containing the updateSession function and a isPending state + * @example + * const Page = () => { + * const { session } = useSession() + * const { updateSession, isPending } = useUpdateSession() + * + * const handleUpdate = async () => { + * if (session) { + * await updateSession({ session: { user: { name: "New Name" } } }) + * } + * } + * + * return ( + *
+ *

Name: {session?.user.name}

+ * + *
+ * ) + * } */ -export const useSignOut = (defaultOptions?: SignOutOptions) => { - const { signOut } = useAuth() - const defaultsRef = useRef(defaultOptions) - defaultsRef.current = defaultOptions - return useCallback((signOutOptions?: SignOutOptions) => signOut({ ...defaultsRef.current, ...signOutOptions }), [signOut]) +export const useUpdateSession = () => { + const { client } = useAssertContext() + const { execute, isPending } = useAsyncAction() + + const updateSession = useCallback( + >( + options: Options + ): Promise> => { + return execute(async () => { + const updated = await client.updateSession(options) + broadcast({ type: "session:update", payload: (updated as any).session }) + return updated + }) + }, + [client, execute] + ) + + return { updateSession, isPending } as const } /** - * Patch session user/expiry. Default {`@link` UpdateSessionOptions} are merged with per-invocation options - * (e.g. `redirect: false` to avoid a follow-up redirect and trigger a context refresh instead). + * Signs out the current user. + * + * @returns An object containing the signOut function and a isPending state + * @example + * const Page = () => { + * const { signOut, isPending } = useSignOut() + * return ( + * + * ) + * } */ -export const useUpdateSession = (defaultOptions?: UpdateSessionOptions) => { - const { updateSession } = useAuth() - const defaultsRef = useRef(defaultOptions) - defaultsRef.current = defaultOptions - return useCallback( - (options: UpdateSessionOptions) => updateSession({ ...defaultsRef.current, ...options }), - [updateSession] +export const useSignOut = () => { + const { client } = useAssertContext() + const { execute, isPending } = useAsyncAction() + + const signOut = useCallback( + (options?: Options): Promise> => { + return execute(async () => { + const value = await client.signOut(options) + broadcast({ type: "session:clear" }) + return value + }) + }, + [client, execute] ) + + return { signOut, isPending } as const +} + +/** + * Centralized hook that provides all authentication actions and their pending states. + * + * @returns An object containing all auth actions (signIn, signInCredentials, updateSession, signOut) and a combined isPending state + * @example + * const Page = () => { + * const { signIn, signInCredentials, updateSession, signOut, isPending } = useAuthActions() + * // Use the actions as needed in your component + * return

Auth actions are ready to use. isPending: {isPending ? "Yes" : "No"}

+ * } + */ +export const useAuthActions = () => { + const { signIn, isPending: isSignInPending } = useSignIn() + const { signInCredentials, isPending: isSignInCredentialsPending } = useSignInCredentials() + const { updateSession, isPending: isUpdateSessionPending } = useUpdateSession() + const { signOut, isPending: isSignOutPending } = useSignOut() + + return { + isPending: isSignInPending || isSignInCredentialsPending || isUpdateSessionPending || isSignOutPending, + signIn, + signInCredentials, + updateSession, + signOut, + } as const } diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx index a25a8cdf..c830521c 100644 --- a/packages/react/src/index.tsx +++ b/packages/react/src/index.tsx @@ -1,4 +1,4 @@ export { createAuthClient, type AuthClientOptions } from "@aura-stack/auth/client" export { AuthProvider, type AuthProviderProps } from "@/context.tsx" -export { useAuth, useSession, useSignIn, useSignInCredentials, useSignOut, useUpdateSession } from "@/hooks.ts" +export { useSession, useAuthActions, useSignIn, useSignInCredentials, useSignOut, useUpdateSession } from "@/hooks.ts" export type { User, Session, AuthConfig, AuthInstance } from "@aura-stack/auth" diff --git a/packages/react/test/hooks.test.tsx b/packages/react/test/hooks.test.tsx index f229a770..368d2bc8 100644 --- a/packages/react/test/hooks.test.tsx +++ b/packages/react/test/hooks.test.tsx @@ -1,7 +1,7 @@ import { act, renderHook, waitFor } from "@testing-library/react" -import { describe, expect, test, vi } from "vitest" +import { afterEach, describe, expect, test, vi } from "vitest" import { AuthProvider } from "@/context.tsx" -import { useAuth, useSession, useSignIn, useSignInCredentials, useSignOut, useUpdateSession } from "@/hooks.ts" +import { useSession, useSignIn, useSignInCredentials, useSignOut, useUpdateSession } from "@/hooks.ts" import type { ReactNode } from "react" import type { AuthClientInstance } from "@/@types/types.ts" @@ -23,6 +23,11 @@ const wrapper = ({ children, client, initialSession }: { children: ReactNode; cl
) +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + describe("@aura-stack/react hooks", () => { test("useSession with initialSession", async () => { const client = createMockClient() @@ -42,7 +47,7 @@ describe("@aura-stack/react hooks", () => { }) await act(async () => { - await result.current("github", { redirect: false }) + await result.current.signIn("github", { redirect: false }) }) expect(client.signIn).toHaveBeenCalledWith("github", { redirect: false }) @@ -56,7 +61,7 @@ describe("@aura-stack/react hooks", () => { }) await act(async () => { - await result.current("github", { redirectTo: "/dashboard", redirect: true }) + await result.current.signIn("github", { redirectTo: "/dashboard", redirect: true }) }) expect(client.signIn).toHaveBeenCalledWith("github", { @@ -73,17 +78,33 @@ describe("@aura-stack/react hooks", () => { const payload = { username: "test@example.com", password: "password" } await act(async () => { - await result.current({ + await result.current.signInCredentials({ payload, redirect: false, }) }) expect(client.signInCredentials).toHaveBeenCalledWith({ payload, redirect: false }) - await waitFor(() => expect(client.getSession).toHaveBeenCalledTimes(1)) }) - test("useSignOut calls client.signOut and refreshes session when redirect is false", async () => { + test("useSignInCredentials with redirectTo", async () => { + const client = createMockClient() + const { result } = renderHook(() => useSignInCredentials(), { + wrapper: ({ children }) => wrapper({ children, client, initialSession: null }), + }) + + const payload = { username: "test@example.com", password: "password" } + await act(async () => { + await result.current.signInCredentials({ + payload, + redirectTo: "/dashboard", + }) + }) + + expect(client.signInCredentials).toHaveBeenCalledWith({ payload, redirectTo: "/dashboard" }) + }) + + test("useSignOut with redirect: false", async () => { const client = createMockClient() client.getSession = vi.fn().mockResolvedValueOnce(null) @@ -92,11 +113,10 @@ describe("@aura-stack/react hooks", () => { }) await act(async () => { - await result.current({ redirect: false }) + await result.current.signOut({ redirect: false }) }) expect(client.signOut).toHaveBeenCalledWith({ redirect: false }) - await waitFor(() => expect(client.getSession).toHaveBeenCalledTimes(1)) }) test("useUpdateSession with refresh", async () => { @@ -107,35 +127,10 @@ describe("@aura-stack/react hooks", () => { const partial = { session: { user: { name: "New Name" } }, redirect: false } await act(async () => { - await result.current(partial) + await result.current.updateSession(partial) }) expect(client.updateSession).toHaveBeenCalledWith(partial) await waitFor(() => expect(client.getSession).toHaveBeenCalledTimes(1)) }) - - test("useAuth returns full context value", async () => { - const client = createMockClient() - const { result } = renderHook(() => useAuth(), { - wrapper: ({ children }) => wrapper({ children, client, initialSession: mockSession }), - }) - - expect(result.current.session).toEqual(mockSession) - expect(result.current.status).toBe("authenticated") - expect(typeof result.current.signIn).toBe("function") - expect(typeof result.current.signOut).toBe("function") - expect(result.current.client).toBe(client) - }) - - test("context status transitions from loading to authenticated on mount", async () => { - const client = createMockClient() - const { result } = renderHook(() => useAuth(), { - wrapper: ({ children }) => wrapper({ children, client }), - }) - - expect(result.current.status).toBe("loading") - - await waitFor(() => expect(result.current.status).toBe("authenticated")) - expect(result.current.session).toEqual(mockSession) - }) })