diff --git a/.vscode/settings.json b/.vscode/settings.json index 985e77b2..1196cf94 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,23 @@ { "editor.formatOnSave": true, - "oxc.fmt.configPath": ".oxfmtrc.json", - "editor.defaultFormatter": "oxc.oxc-vscode" + "oxc.fmt.configPath": "./.oxfmtrc.json", + "editor.defaultFormatter": "oxc.oxc-vscode", + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[json]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + } } diff --git a/apps/astro/package.json b/apps/astro/package.json index d0297381..dc7213f3 100644 --- a/apps/astro/package.json +++ b/apps/astro/package.json @@ -17,6 +17,7 @@ "@astrojs/node": "^10.0.2", "@astrojs/react": "^5.0.0", "@aura-stack/auth": "workspace:*", + "@aura-stack/react": "workspace:*", "@radix-ui/react-slot": "^1.2.4", "astro": "^6.0.5", "lucide-react": "catalog:lucide-react", diff --git a/apps/astro/src/components/auth-client.tsx b/apps/astro/src/components/auth-client.tsx index 2aae38d9..dcac798e 100644 --- a/apps/astro/src/components/auth-client.tsx +++ b/apps/astro/src/components/auth-client.tsx @@ -1,10 +1,12 @@ import { LayoutDashboard } from "lucide-react" -import { useAuth, AuthProvider } from "@/contexts/auth" import { Button } from "@/components/ui/button" +import { useAuth } from "@aura-stack/react" +import { AuthProvider } from "@/contexts/auth" import type { Session } from "@aura-stack/auth" const AuthClientContent = () => { - const { session, isAuthenticated, isLoading, signIn, signOut } = useAuth() + const { session, status, isPending, signIn, signOut } = useAuth() + const isAuthenticated = status === "authenticated" return (
@@ -34,7 +36,7 @@ const AuthClientContent = () => { {session?.user?.sub}
- @@ -54,7 +56,7 @@ const AuthClientContent = () => { className="w-full rounded-none" variant="outline" size="sm" - disabled={isLoading} + disabled={isPending} onClick={() => signIn(provider.toLowerCase())} > Sign In with {provider} @@ -72,7 +74,7 @@ const AuthClientContent = () => { export const AuthClient = (props: { session?: Session | null }) => { return ( - + ) diff --git a/apps/astro/src/components/header.tsx b/apps/astro/src/components/header.tsx index a025a7a8..5e9541a5 100644 --- a/apps/astro/src/components/header.tsx +++ b/apps/astro/src/components/header.tsx @@ -1,12 +1,14 @@ import { useState } from "react" import { Menu, X } from "lucide-react" import { Button } from "@/components/ui/button" -import { useAuth, AuthProvider } from "@/contexts/auth" +import { useAuth } from "@aura-stack/react" +import { AuthProvider } from "@/contexts/auth" import type { Session } from "@aura-stack/auth" const HeaderContent = () => { const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const { isAuthenticated, isLoading, signOut, signIn } = useAuth() + const { status, isPending, signOut, signIn } = useAuth() + const isAuthenticated = status === "authenticated" const handleSignOut = async () => { await signOut() @@ -103,7 +105,7 @@ const HeaderContent = () => { Discord
- {!isLoading && !isAuthenticated && ( + {!isPending && !isAuthenticated && ( @@ -125,7 +127,7 @@ const HeaderContent = () => { export const Header = (props: { session?: Session }) => { return ( - + ) diff --git a/apps/astro/src/contexts/auth.tsx b/apps/astro/src/contexts/auth.tsx index eff695ea..3d9518e2 100644 --- a/apps/astro/src/contexts/auth.tsx +++ b/apps/astro/src/contexts/auth.tsx @@ -1,62 +1,10 @@ -import { createContext, use, useState, useEffect } from "react" +import { AuthProvider as AuraAuthProvider, type AuthProviderProps } from "@aura-stack/react" import { authClient } from "@/lib/client" -import type { Session, LiteralUnion, BuiltInOAuthProvider, SignInOptions, SignOutOptions } from "@aura-stack/auth" -import type { AuthProviderProps } from "@/@types/props" -import type { AuthContextValue } from "@/@types/types" -export const AuthContext = createContext(undefined) - -export const AuthProvider = ({ children, session: defaultSession }: AuthProviderProps) => { - const [isLoading, setIsLoading] = useState(defaultSession === undefined) - const [session, setSession] = useState(defaultSession ?? null) - const isAuthenticated = Boolean(session?.user) - - const signIn = async (provider: LiteralUnion, options?: SignInOptions) => { - setIsLoading(true) - try { - return await authClient.signIn(provider, options) - } finally { - setIsLoading(false) - } - } - - const signOut = async (options?: SignOutOptions) => { - setIsLoading(true) - try { - const value = await authClient.signOut(options) - setSession(null) - return value - } finally { - setIsLoading(false) - } - } - - useEffect(() => { - if (defaultSession !== undefined) { - setSession(defaultSession) - setIsLoading(false) - return - } - const fetchSession = async () => { - try { - const session = await authClient.getSession() - setSession(session) - } catch { - setSession(null) - } finally { - setIsLoading(false) - } - } - fetchSession() - }, [defaultSession]) - - return {children} -} - -export const useAuth = () => { - const ctx = use(AuthContext) - if (!ctx) { - throw new Error("useAuth must be used within an AuthProvider") - } - return ctx +export const AuthProvider = ({ children, initialSession }: Omit) => { + return ( + + {children} + + ) } diff --git a/apps/astro/tsconfig.json b/apps/astro/tsconfig.json index f7647656..07c91c8e 100644 --- a/apps/astro/tsconfig.json +++ b/apps/astro/tsconfig.json @@ -7,7 +7,6 @@ "jsxImportSource": "react", "paths": { "@/*": ["./src/*"] - }, - "baseUrl": "./" + } } } diff --git a/apps/nextjs/app-router/package.json b/apps/nextjs/app-router/package.json index 06b631d4..aa4195d8 100644 --- a/apps/nextjs/app-router/package.json +++ b/apps/nextjs/app-router/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@aura-stack/auth": "workspace:*", + "@aura-stack/react": "workspace:*", "@radix-ui/react-slot": "^1.2.4", "lucide-react": "catalog:lucide-react", "next": "catalog:next", diff --git a/apps/nextjs/app-router/src/app/layout.tsx b/apps/nextjs/app-router/src/app/layout.tsx index 88274311..bb429dea 100644 --- a/apps/nextjs/app-router/src/app/layout.tsx +++ b/apps/nextjs/app-router/src/app/layout.tsx @@ -1,9 +1,9 @@ import { Geist, Geist_Mono } from "next/font/google" import { Header } from "@/components/header" -import { AuthProvider } from "@/contexts/auth" import { Footer } from "@/components/footer" import { metadataInfo } from "@/lib/metadata" -import "./globals.css" +import { AuthProvider } from "@/contexts/auth" +import "@/app/globals.css" const geistSans = Geist({ variable: "--font-geist-sans", diff --git a/apps/nextjs/app-router/src/components/auth-client.tsx b/apps/nextjs/app-router/src/components/auth-client.tsx index 1dd2de99..3f919b5b 100644 --- a/apps/nextjs/app-router/src/components/auth-client.tsx +++ b/apps/nextjs/app-router/src/components/auth-client.tsx @@ -1,11 +1,12 @@ "use client" -import { useAuth } from "@/contexts/auth" import { LayoutDashboard } from "lucide-react" import { Button } from "./ui/button" +import { useAuth } from "@aura-stack/react/hooks" export const AuthClient = () => { - const { session, isAuthenticated, isLoading, signIn, signOut } = useAuth() + const { session, status, isPending, signIn, signOut } = useAuth() + const isAuthenticated = status === "authenticated" return (
@@ -35,7 +36,7 @@ export const AuthClient = () => { {session?.user?.sub}
- @@ -55,7 +56,7 @@ export const AuthClient = () => { className="w-full rounded-none" variant="outline" size="sm" - disabled={isLoading} + disabled={isPending} onClick={() => signIn(provider.toLowerCase())} > Sign In with {provider} diff --git a/apps/nextjs/app-router/src/components/header.tsx b/apps/nextjs/app-router/src/components/header.tsx index d91067fe..cfc8f191 100644 --- a/apps/nextjs/app-router/src/components/header.tsx +++ b/apps/nextjs/app-router/src/components/header.tsx @@ -3,12 +3,13 @@ import Link from "next/link" import { useState } from "react" import { Menu, X } from "lucide-react" -import { useAuth } from "@/contexts/auth" import { Button } from "@/components/ui/button" +import { useAuth } from "@aura-stack/react" export const Header = () => { const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const { isAuthenticated, isLoading, signOut, signIn } = useAuth() + const { status, isPending, signOut, signIn } = useAuth() + const isAuthenticated = status === "authenticated" const handleSignOut = async () => { await signOut() @@ -102,7 +103,7 @@ export const Header = () => { Discord
- {!isLoading && !isAuthenticated && ( + {!isPending && !isAuthenticated && ( diff --git a/apps/nextjs/app-router/src/contexts/auth.tsx b/apps/nextjs/app-router/src/contexts/auth.tsx index eccf52df..d07de890 100644 --- a/apps/nextjs/app-router/src/contexts/auth.tsx +++ b/apps/nextjs/app-router/src/contexts/auth.tsx @@ -1,76 +1,11 @@ "use client" - -import { createContext, use, useState, useEffect } from "react" +import { AuthProvider as AuraAuthProvider, type AuthProviderProps } from "@aura-stack/react" import { authClient } from "@/lib/auth-client" -import type { Session, LiteralUnion, BuiltInOAuthProvider, SignInOptions, SignOutOptions } from "@aura-stack/auth" -import type { AuthContextValue } from "@/@types/types" -import type { AuthProviderProps } from "@/@types/props" - -export const AuthContext = createContext(undefined) - -export const AuthProvider = ({ children, session: defaultSession }: AuthProviderProps) => { - const [isLoading, setIsLoading] = useState(defaultSession === undefined) - const [session, setSession] = useState(defaultSession ?? null) - const isAuthenticated = Boolean(session?.user) - - const signIn = async (provider: LiteralUnion, options?: SignInOptions) => { - setIsLoading(true) - try { - return await authClient.signIn(provider, { redirect: true, ...options }) - } finally { - setIsLoading(false) - } - } - - const signOut = async (options?: SignOutOptions) => { - setIsLoading(true) - try { - const value = await authClient.signOut(options) - setSession(null) - return value - } finally { - setIsLoading(false) - } - } - - useEffect(() => { - if (defaultSession !== undefined) { - setSession(defaultSession) - setIsLoading(false) - return - } - const fetchSession = async () => { - try { - const session = await authClient.getSession() - setSession(session) - } catch { - setSession(null) - } finally { - setIsLoading(false) - } - } - fetchSession() - }, [defaultSession]) +export const AuthProvider = ({ children, initialSession }: Omit) => { return ( - + {children} - + ) } - -export const useAuth = () => { - const ctx = use(AuthContext) - if (!ctx) { - throw new Error("useAuth must be used within an AuthProvider") - } - return ctx -} diff --git a/apps/nextjs/pages-router/package.json b/apps/nextjs/pages-router/package.json index a1b8aa8e..d28b8691 100644 --- a/apps/nextjs/pages-router/package.json +++ b/apps/nextjs/pages-router/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@aura-stack/auth": "workspace:*", + "@aura-stack/react": "workspace:*", "@radix-ui/react-slot": "^1.2.4", "lucide-react": "catalog:lucide-react", "next": "catalog:next", diff --git a/apps/nextjs/pages-router/src/components/auth-client.tsx b/apps/nextjs/pages-router/src/components/auth-client.tsx index c7a4e994..365d97c1 100644 --- a/apps/nextjs/pages-router/src/components/auth-client.tsx +++ b/apps/nextjs/pages-router/src/components/auth-client.tsx @@ -1,9 +1,10 @@ -import { useAuth } from "@/contexts/auth" import { LayoutDashboard } from "lucide-react" import { Button } from "./ui/button" +import { useAuth } from "@aura-stack/react" export const AuthClient = () => { - const { session, isAuthenticated, isLoading, signIn, signOut } = useAuth() + const { session, status, isPending, signIn, signOut } = useAuth() + const isAuthenticated = status === "authenticated" return (
@@ -33,7 +34,7 @@ export const AuthClient = () => { {session?.user?.sub}
- @@ -53,7 +54,7 @@ export const AuthClient = () => { className="w-full rounded-none" variant="outline" size="sm" - disabled={isLoading} + disabled={isPending} onClick={() => signIn(provider.toLowerCase())} > Sign In with {provider} diff --git a/apps/nextjs/pages-router/src/components/header.tsx b/apps/nextjs/pages-router/src/components/header.tsx index 34ccc0b9..9e23838a 100644 --- a/apps/nextjs/pages-router/src/components/header.tsx +++ b/apps/nextjs/pages-router/src/components/header.tsx @@ -2,13 +2,14 @@ import Link from "next/link" import { useRouter } from "next/router" import { useState } from "react" import { Menu, X } from "lucide-react" -import { useAuth } from "@/contexts/auth" import { Button } from "@/components/ui/button" +import { useAuth } from "@aura-stack/react" export const Header = () => { const router = useRouter() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const { isAuthenticated, isLoading, signOut, signIn } = useAuth() + const { status, isPending, signOut, signIn } = useAuth() + const isAuthenticated = status === "authenticated" const handleSignOut = async () => { await signOut() @@ -100,7 +101,7 @@ export const Header = () => { Discord
- {!isLoading && !isAuthenticated && ( + {!isPending && !isAuthenticated && ( <>
- @@ -55,7 +56,7 @@ export const AuthClient = () => { className="w-full rounded-none" variant="outline" size="sm" - disabled={isLoading} + disabled={isPending} onClick={() => signIn(provider.toLowerCase())} > Sign In with {provider} diff --git a/apps/react-router/app/components/header.tsx b/apps/react-router/app/components/header.tsx index 690dd0a3..25ab72a2 100644 --- a/apps/react-router/app/components/header.tsx +++ b/apps/react-router/app/components/header.tsx @@ -1,13 +1,14 @@ import { useState } from "react" import { Menu, X } from "lucide-react" -import { useAuth } from "~/contexts/auth" import { Button } from "~/components/ui/button" import { Link, useRevalidator } from "react-router" +import { useAuth } from "@aura-stack/react" export const Header = () => { const revalidator = useRevalidator() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const { isAuthenticated, isLoading, signOut, signIn } = useAuth() + const { status, isPending, signOut, signIn } = useAuth() + const isAuthenticated = status === "authenticated" const handleSignOut = async () => { await signOut() @@ -98,7 +99,7 @@ export const Header = () => { Discord
- {!isLoading && !isAuthenticated && ( + {!isPending && !isAuthenticated && ( <> + } + + return ( +
+

Welcome, {session.user.name}!

+ +
+ ) +} +``` + +## Documentation + +Visit the [**official documentation website**](https://aura-stack-auth.vercel.app) for more detailed guides and API references. + +## License + +Licensed under the [MIT License](../../LICENSE). © [Aura Stack](https://github.com/aura-stack-ts) + +--- + +

+ Made with ❤️ by Aura Stack team +

diff --git a/packages/react/deno.json b/packages/react/deno.json new file mode 100644 index 00000000..798f9149 --- /dev/null +++ b/packages/react/deno.json @@ -0,0 +1,21 @@ +{ + "name": "@aura-stack/react", + "version": "0.0.0", + "license": "MIT", + "tasks": { + "dev": "deno run --watch src/index.tsx" + }, + "exports": { + ".": "./src/index.tsx", + "./hooks": "./src/hooks.ts", + "./context": "./src/context.ts", + "./types": "./src/types.ts" + }, + "imports": { + "@/": "./src/" + }, + "publish": { + "include": ["src/**/*.ts", "src/**/*.tsx", "README.md", "CHANGELOG.md"] + }, + "exclude": ["dist", "node_modules"] +} diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 00000000..e8b0537c --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,69 @@ +{ + "name": "@aura-stack/react", + "version": "0.0.0", + "private": false, + "type": "module", + "description": "", + "scripts": { + "dev": "tsup --watch", + "build": "tsup", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "test": "vitest --run", + "test:watch": "vitest", + "test:coverage": "vitest --run --coverage", + "format": "oxfmt", + "format:check": "oxfmt --check", + "type-check": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/aura-stack-ts/auth" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./hooks": { + "types": "./dist/hooks.d.ts", + "import": "./dist/hooks.js", + "require": "./dist/hooks.cjs" + }, + "./context": { + "types": "./dist/context.d.ts", + "import": "./dist/context.js", + "require": "./dist/context.cjs" + }, + "./types": "./dist/types.d.ts" + }, + "keywords": [], + "author": "Aura Stack | Hernan Alvarado ", + "homepage": "https://aura-stack-auth.vercel.app", + "bugs": { + "url": "https://github.com/aura-stack-ts/auth/issues" + }, + "license": "MIT", + "dependencies": { + "@aura-stack/auth": "workspace:*" + }, + "devDependencies": { + "@aura-stack/tsconfig": "workspace:*", + "@aura-stack/tsup-config": "workspace:*", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2" + }, + "peerDependencies": { + "react": ">=19.0.0", + "@types/react": ">=19.0.0" + }, + "packageManager": "pnpm@10.15.0" +} diff --git a/packages/react/src/context.tsx b/packages/react/src/context.tsx new file mode 100644 index 00000000..3edf8ddd --- /dev/null +++ b/packages/react/src/context.tsx @@ -0,0 +1,128 @@ +"use client" + +import { createContext, useCallback, useEffect, useMemo, useState, useTransition } from "react" +import type { + CredentialsPayload, + DeepPartial, + LiteralUnion, + BuiltInOAuthProvider, + Session, + SignInOptions, + SignOutOptions, + User, +} from "@aura-stack/auth" +import type { AuthProviderProps, AuthReactContextValue, UpdateSessionCallOptions } from "@/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) + +/** + * 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. + */ +export const AuthProvider = ({ + children, + client, + initialSession, +}: AuthProviderProps) => { + const [session, setSession] = useState | null | undefined>(initialSession) + const [isPending, startTransition] = useTransition() + + const status = useMemo((): AuthReactContextValue["status"] => { + if (session === undefined) return "loading" + return session ? "authenticated" : "unauthenticated" + }, [session]) + + const refresh = useCallback(async () => { + startTransition(async () => { + const next = await client.getSession() + setSession(next) + }) + }, [client]) + + const signIn = useCallback( + async (oauth: LiteralUnion, signInOptions?: SignInOptions) => { + const result = await client.signIn(oauth, signInOptions) + if (!(signInOptions?.redirect ?? true)) { + await refresh() + } + return result + }, + [client, refresh] + ) + + const signInCredentials = useCallback( + async (credentials: CredentialsPayload, signInOptions?: SignInOptions) => { + const result = await client.signInCredentials(credentials, signInOptions) + if (!(signInOptions?.redirect ?? true)) { + await refresh() + } + return result + }, + [client, refresh] + ) + + const signOut = useCallback( + async (signOutOptions?: SignOutOptions) => { + const result = await client.signOut(signOutOptions) + if (!(signOutOptions?.redirect ?? true)) { + await refresh() + } + return result + }, + [client, refresh] + ) + + const updateSession = useCallback( + async (partial: DeepPartial>, callOptions?: UpdateSessionCallOptions) => { + const result = await client.updateSession(partial) + if (!callOptions?.skipRefresh) { + await refresh() + } + 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) + }) + } + })() + + return () => { + cancelled = true + } + }, [initialSession, client]) + + const value = useMemo( + (): AuthReactContextValue => ({ + session, + status, + isPending, + client, + refresh, + signIn, + signInCredentials, + signOut, + updateSession, + }), + [session, status, isPending, client, refresh, signIn, signInCredentials, signOut, updateSession] + ) + + return }>{children} +} diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts new file mode 100644 index 00000000..a0cb097a --- /dev/null +++ b/packages/react/src/hooks.ts @@ -0,0 +1,82 @@ +"use client" + +import { use, useCallback, useRef } from "react" +import type { + CredentialsPayload, + DeepPartial, + LiteralUnion, + BuiltInOAuthProvider, + Session, + SignInOptions, + SignOutOptions, + User, +} from "@aura-stack/auth/types" +import { AuthContext } from "@/context.tsx" +import type { AuthReactContextValue, UpdateSessionCallOptions } from "@/types.ts" + +export const useAuth = (): AuthReactContextValue => { + const ctx = use(AuthContext) + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider.") + } + return ctx as AuthReactContextValue +} + +export const useSession = () => { + const { session, status } = useAuth() + return { session, status } +} + +/** + * OAuth sign-in. Pass default {@link SignInOptions} once; each call can still override + * `redirect`, `redirectTo`, etc. Call-time options win on conflict. + */ +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] + ) +} + +/** + * Credentials sign-in. Default {@link SignInOptions} are merged with per-invocation options. + */ +export const useSignInCredentials = (defaultOptions?: SignInOptions) => { + const { signInCredentials } = useAuth() + const defaultsRef = useRef(defaultOptions) + defaultsRef.current = defaultOptions + return useCallback( + (credentials: CredentialsPayload, signInOptions?: SignInOptions) => + signInCredentials(credentials, { ...defaultsRef.current, ...signInOptions }), + [signInCredentials] + ) +} + +/** + * Sign-out. Default {@link SignOutOptions} (`redirect`, `redirectTo`, …) merge with each call. + */ +export const useSignOut = (defaultOptions?: SignOutOptions) => { + const { signOut } = useAuth() + const defaultsRef = useRef(defaultOptions) + defaultsRef.current = defaultOptions + return useCallback((signOutOptions?: SignOutOptions) => signOut({ ...defaultsRef.current, ...signOutOptions }), [signOut]) +} + +/** + * Patch session user/expiry. Default {@link UpdateSessionCallOptions} merge per call + * (e.g. `skipRefresh` to avoid a follow-up `getSession`). + */ +export const useUpdateSession = (defaultOptions?: UpdateSessionCallOptions) => { + const { updateSession } = useAuth() + const defaultsRef = useRef(defaultOptions) + defaultsRef.current = defaultOptions + return useCallback( + (partial: DeepPartial>, callOptions?: UpdateSessionCallOptions) => + updateSession(partial, { ...defaultsRef.current, ...callOptions }), + [updateSession] + ) +} diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx new file mode 100644 index 00000000..e6b3578b --- /dev/null +++ b/packages/react/src/index.tsx @@ -0,0 +1,3 @@ +export { AuthProvider } from "@/context.tsx" +export { useAuth, useSession, useSignIn, useSignInCredentials, useSignOut, useUpdateSession } from "@/hooks.ts" +export type { AuthProviderProps, AuthClientInstance, AuthReactContextValue, AuthStatus } from "@/types.ts" diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts new file mode 100644 index 00000000..084f71c3 --- /dev/null +++ b/packages/react/src/types.ts @@ -0,0 +1,60 @@ +import type { ReactNode } from "react" +import type { + CredentialsPayload, + DeepPartial, + LiteralUnion, + BuiltInOAuthProvider, + Session, + SignInOptions, + SignOutOptions, + User, +} from "@aura-stack/auth/types" + +export type AuthClientInstance = ReturnType< + typeof import("@aura-stack/auth/client").createAuthClient +> + +export type AuthStatus = "authenticated" | "unauthenticated" | "loading" + +/** Options for {@link AuthReactContextValue.updateSession} (React layer; not sent to the HTTP API). */ +export type UpdateSessionCallOptions = { + /** When true, skip syncing session state via {@link AuthReactContextValue.refresh} after the update. */ + skipRefresh?: boolean +} + +/** + * 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 = { + session: Session | null | undefined + status: AuthStatus + /** True while a transition updates session state (e.g. after refresh or a non-redirect sign-in). */ + isPending: boolean + client: AuthClientInstance + refresh: () => Promise + signIn: ( + oauth: LiteralUnion, + options?: SignInOptions + ) => ReturnType["signIn"]> + signInCredentials: ( + credentials: CredentialsPayload, + options?: SignInOptions + ) => ReturnType["signInCredentials"]> + signOut: (options?: SignOutOptions) => ReturnType["signOut"]> + updateSession: ( + partial: DeepPartial>, + options?: UpdateSessionCallOptions + ) => ReturnType["updateSession"]> +} + +export type AuthProviderProps = { + children: ReactNode + /** Auth API client from {@link createAuthClient}; swap this instance whenever your app needs a different client. */ + client: AuthClientInstance + /** + * Server-rendered session when available. Omit or pass `undefined` to fetch on mount. + * Pass `null` when the server knows there is no session (skip the initial client fetch). + */ + initialSession?: Session | null +} diff --git a/packages/react/test/hooks.test.tsx b/packages/react/test/hooks.test.tsx new file mode 100644 index 00000000..3f6c6fc4 --- /dev/null +++ b/packages/react/test/hooks.test.tsx @@ -0,0 +1,153 @@ +import { act, renderHook, waitFor } from "@testing-library/react" +import { describe, expect, test, vi } from "vitest" +import { AuthProvider } from "@/context.tsx" +import { useAuth, useSession, useSignIn, useSignInCredentials, useSignOut, useUpdateSession } from "@/hooks.ts" +import type { ReactNode } from "react" +import type { AuthClientInstance } from "@/types.ts" + +const mockUser = { id: "1", email: "test@example.com", name: "Test User" } +const mockSession = { user: mockUser, expires: new Date(Date.now() + 3600 * 1000).toISOString() } + +const createMockClient = () => + ({ + getSession: vi.fn().mockResolvedValue(mockSession), + signIn: vi.fn().mockResolvedValue({ url: "/api/auth/signin" }), + signInCredentials: vi.fn().mockResolvedValue({ url: "/api/auth/signin" }), + signOut: vi.fn().mockResolvedValue({ url: "/api/auth/signout" }), + updateSession: vi.fn().mockResolvedValue(mockSession), + }) as unknown as AuthClientInstance + +const wrapper = ({ children, client, initialSession }: { children: ReactNode; client: any; initialSession?: any }) => ( + + {children} + +) + +describe("@aura-stack/react hooks", () => { + test("useSession with initialSession", async () => { + const client = createMockClient() + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }) => wrapper({ children, client, initialSession: mockSession }), + }) + + expect(result.current.session).toEqual(mockSession) + expect(result.current.status).toBe("authenticated") + expect(client.getSession).not.toHaveBeenCalled() + }) + + test("useSignIn with redirect: false", async () => { + const client = createMockClient() + const { result } = renderHook(() => useSignIn(), { + wrapper: ({ children }) => wrapper({ children, client, initialSession: null }), + }) + + await act(async () => { + await result.current("github", { redirect: false }) + }) + + expect(client.signIn).toHaveBeenCalledWith("github", { redirect: false }) + await waitFor(() => expect(client.getSession).toHaveBeenCalledTimes(1)) + }) + + test("useSignIn with redirectTo", async () => { + const client = createMockClient() + const { result } = renderHook(() => useSignIn(), { + wrapper: ({ children }) => wrapper({ children, client, initialSession: null }), + }) + + await act(async () => { + await result.current("github", { redirectTo: "/dashboard", redirect: true }) + }) + + expect(client.signIn).toHaveBeenCalledWith("github", { + redirectTo: "/dashboard", + redirect: true, + }) + }) + + test("useSignInCredentials with redirect: false", async () => { + const client = createMockClient() + const { result } = renderHook(() => useSignInCredentials(), { + wrapper: ({ children }) => wrapper({ children, client, initialSession: null }), + }) + + const credentials = { username: "test@example.com", password: "password" } + await act(async () => { + await result.current(credentials, { redirect: false }) + }) + + expect(client.signInCredentials).toHaveBeenCalledWith(credentials, { redirect: false }) + await waitFor(() => expect(client.getSession).toHaveBeenCalledTimes(1)) + }) + + test("useSignOut calls client.signOut and refreshes session when redirect is false", async () => { + const client = createMockClient() + client.getSession = vi.fn().mockResolvedValueOnce(null) + + const { result } = renderHook(() => useSignOut(), { + wrapper: ({ children }) => wrapper({ children, client, initialSession: mockSession }), + }) + + await act(async () => { + await result.current({ redirect: false }) + }) + + expect(client.signOut).toHaveBeenCalledWith({ redirect: false }) + await waitFor(() => expect(client.getSession).toHaveBeenCalledTimes(1)) + }) + + test("useUpdateSession with refresh", async () => { + const client = createMockClient() + const { result } = renderHook(() => useUpdateSession(), { + wrapper: ({ children }) => wrapper({ children, client, initialSession: mockSession }), + }) + + const partial = { user: { name: "New Name" } } + await act(async () => { + await result.current(partial) + }) + + expect(client.updateSession).toHaveBeenCalledWith(partial) + await waitFor(() => expect(client.getSession).toHaveBeenCalledTimes(1)) + }) + + test("useUpdateSession with skipRefresh", async () => { + const client = createMockClient() + const { result } = renderHook(() => useUpdateSession(), { + wrapper: ({ children }) => wrapper({ children, client, initialSession: mockSession }), + }) + + const partial = { user: { name: "New Name" } } + await act(async () => { + await result.current(partial, { skipRefresh: true }) + }) + + expect(client.updateSession).toHaveBeenCalledWith(partial) + expect(client.getSession).not.toHaveBeenCalled() + }) + + 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) + }) +}) diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 00000000..683985e3 --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@aura-stack/tsconfig/tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "noEmit": true, + "paths": { + "@/*": ["./src/*"], + "@test/*": ["./test/*"] + } + }, + "include": ["src", "test"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts new file mode 100644 index 00000000..d970bafe --- /dev/null +++ b/packages/react/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup" +import { tsupConfig } from "@aura-stack/tsup-config" + +export default defineConfig({ + ...tsupConfig, + banner: { + js: `"use client"`, + }, +}) diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 00000000..696d778f --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,19 @@ +import path from "node:path" +import { fileURLToPath } from "node:url" +import { defineConfig } from "vitest/config" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + test: { + include: ["test/**/*.test.tsx", "test/**/*.test.ts"], + environment: "jsdom", + globals: true, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@test": path.resolve(__dirname, "./test"), + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9896c3d..4b44bc28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: '@aura-stack/auth': specifier: workspace:* version: link:../../packages/core + '@aura-stack/react': + specifier: workspace:* + version: link:../../packages/react '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.4) @@ -241,6 +244,9 @@ importers: '@aura-stack/auth': specifier: workspace:* version: link:../../../packages/core + '@aura-stack/react': + specifier: workspace:* + version: link:../../../packages/react '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.4) @@ -287,6 +293,9 @@ importers: '@aura-stack/auth': specifier: workspace:* version: link:../../../packages/core + '@aura-stack/react': + specifier: workspace:* + version: link:../../../packages/react '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.4) @@ -367,6 +376,9 @@ importers: '@aura-stack/auth': specifier: workspace:* version: link:../../packages/core + '@aura-stack/react': + specifier: workspace:* + version: link:../../packages/react '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.4) @@ -434,6 +446,9 @@ importers: '@aura-stack/auth': specifier: workspace:* version: link:../../packages/core + '@aura-stack/react': + specifier: workspace:* + version: link:../../packages/react '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) @@ -478,7 +493,7 @@ importers: version: 0.561.0(react@19.2.4) nitro: specifier: npm:nitro-nightly@latest - version: nitro-nightly@3.0.1-20260402-182549-a5a3389c(chokidar@5.0.0)(dotenv@17.3.1)(giget@3.1.2)(ioredis@5.10.0)(jiti@2.6.1)(lru-cache@11.2.7)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)) + version: nitro-nightly@3.0.1-20260409-145609-ab30376e(chokidar@5.0.0)(dotenv@17.3.1)(giget@3.1.2)(ioredis@5.10.0)(jiti@2.6.1)(lru-cache@11.2.7)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)) react: specifier: catalog:react version: 19.2.4 @@ -685,6 +700,31 @@ importers: specifier: workspace:* version: link:../../configs/tsup-config + packages/react: + dependencies: + '@aura-stack/auth': + specifier: workspace:* + version: link:../core + '@types/react': + specifier: '>=19.0.0' + version: 19.2.14 + react: + specifier: '>=19.0.0' + version: 19.2.4 + devDependencies: + '@aura-stack/tsconfig': + specifier: workspace:* + version: link:../../configs/tsconfig + '@aura-stack/tsup-config': + specifier: workspace:* + version: link:../../configs/tsup-config + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + packages: '@acemir/cssom@0.9.31': @@ -6813,8 +6853,8 @@ packages: nf3@0.3.16: resolution: {integrity: sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw==} - nitro-nightly@3.0.1-20260402-182549-a5a3389c: - resolution: {integrity: sha512-FlUsSGgM0DMMl+N8pdWqpAllNguCgj983LUmWpio7HQsDv+mhX0JUc7WMwszBW+d1e+gb2pe6rjrMlJeFFiFUw==} + nitro-nightly@3.0.1-20260409-145609-ab30376e: + resolution: {integrity: sha512-q1nEIMbzn+kqei7nVJ0RnC/EAEaBqQZVeScrni8Ndw1Ztt9HH1GHTH3TdHg7F6NoHV3uphMRtlcCQmmTbAGm0A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -14561,7 +14601,7 @@ snapshots: h3@2.0.1-rc.16(crossws@0.4.4(srvx@0.11.15)): dependencies: rou3: 0.8.1 - srvx: 0.11.12 + srvx: 0.11.15 optionalDependencies: crossws: 0.4.4(srvx@0.11.15) @@ -15832,7 +15872,7 @@ snapshots: nf3@0.3.16: {} - nitro-nightly@3.0.1-20260402-182549-a5a3389c(chokidar@5.0.0)(dotenv@17.3.1)(giget@3.1.2)(ioredis@5.10.0)(jiti@2.6.1)(lru-cache@11.2.7)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)): + nitro-nightly@3.0.1-20260409-145609-ab30376e(chokidar@5.0.0)(dotenv@17.3.1)(giget@3.1.2)(ioredis@5.10.0)(jiti@2.6.1)(lru-cache@11.2.7)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 crossws: 0.4.4(srvx@0.11.15) diff --git a/turbo.json b/turbo.json index 9b121cea..613d71de 100644 --- a/turbo.json +++ b/turbo.json @@ -23,6 +23,9 @@ ".vercel/output/**" ] }, + "@aura-stack/react#build": { + "dependsOn": ["@aura-stack/auth#build"] + }, "test": { "dependsOn": ["^test"], "cache": false