diff --git a/.changeset/auth-forms-server-actions.md b/.changeset/auth-forms-server-actions.md new file mode 100644 index 00000000..b0a33ed5 --- /dev/null +++ b/.changeset/auth-forms-server-actions.md @@ -0,0 +1,52 @@ +--- +'@opensaas/stack-auth': minor +'@opensaas/stack-cli': minor +--- + +Auth forms now submit through app-owned server actions instead of the browser `authClient` + +The pre-built auth forms (`SignInForm`, `SignUpForm`, `ForgotPasswordForm`, and the new +`ResetPasswordForm`) no longer take an `authClient` prop that calls `/api/auth/*` from the +browser. Instead each form takes **server action** props — `'use server'` functions the app +defines against its own `auth` instance. This keeps the auth network surface server-side and +matches the app's existing `lib/actions/*` convention. `createAuth` now auto-adds +better-auth's `nextCookies` plugin, so the session cookie set inside a server action persists. +See ADR-0020. + +The package exports the action contract types (`AuthActionResult`, `SignInInput`, +`SignUpInput`, `RequestPasswordResetInput`, `ResetPasswordInput`, and the action aliases). +`createClient` is unchanged for client-side session reading (`useSession`). + +Migration — define the actions in your app and pass them to the forms: + +```typescript +// lib/actions/auth.ts +'use server' +import { headers } from 'next/headers' +import { auth } from '@/lib/auth' +import type { AuthActionResult, SignInInput } from '@opensaas/stack-auth/ui' + +export async function signInAction(input: SignInInput): Promise { + try { + await auth.api.signInEmail({ + body: { email: input.email, password: input.password }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Sign in failed' } + } +} +``` + +```tsx +// Before + + +// After + +``` + +Social sign-in becomes a redirecting server action passed as `signInSocialAction`. The CLI +feature-generator now scaffolds `lib/actions/auth.ts` and a `reset-password` page, and no +longer emits `lib/auth-client.ts`. diff --git a/CONTEXT.md b/CONTEXT.md index ea1f2ac8..eb187f81 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -72,6 +72,10 @@ _Avoid_: auth tables, auth models, auth schema The better-auth-owned record of who a session belongs to (the better-auth user). Separate from, and not assumed to be, the application's own domain User; an app links the two itself when it needs to. _Avoid_: auth user, principal, account +**Auth action**: +An app-owned `'use server'` function that runs an authentication mutation (sign in, sign up, request/perform password reset, social sign-in) by calling better-auth's server API directly against the app's own auth instance. The pre-built auth forms invoke Auth actions passed as props — one prop per concern — instead of the browser calling the `/api/auth/*` endpoints; the auth instance therefore never leaves the server, and the package owns only the form components and the actions' contract types, never the actions themselves. +_Avoid_: auth handler, form action, authClient call + ### Storage **Storage provider**: diff --git a/docs/adr/0020-auth-forms-submit-through-app-owned-server-actions.md b/docs/adr/0020-auth-forms-submit-through-app-owned-server-actions.md new file mode 100644 index 00000000..b19f2ee8 --- /dev/null +++ b/docs/adr/0020-auth-forms-submit-through-app-owned-server-actions.md @@ -0,0 +1,60 @@ +# Auth forms submit through app-owned server actions, not the browser auth client + +## Context + +The pre-built auth forms (`SignInForm`, `SignUpForm`, `ForgotPasswordForm`, and now +`ResetPasswordForm`) took a better-auth `authClient` prop and called `authClient.*` +from the browser, which hit the `/api/auth/*` REST route directly. We want the +authentication network surface to stay server-side (no browser dependency on +`NEXT_PUBLIC_APP_URL`, fewer origin/CORS concerns) and to match the app's existing +`lib/actions/*` server-action convention. + +## Decision + +The forms now invoke **Auth actions** — app-owned `'use server'` functions that call +better-auth's server API (`auth.api.*`) against the app's own auth instance — passed +in as props, **one prop per concern** (`signInAction`, `signUpAction`, +`requestPasswordResetAction`, `resetPasswordAction`, `signInSocialAction`). The +`authClient` prop is removed from every form. + +- **The package owns only the form components and the actions' contract types** + (`SignInInput`, `SignUpInput`, `RequestPasswordResetInput`, `ResetPasswordInput`, + and `AuthActionResult = { success: true } | { success: false; error: string }`). + It does **not** export shared action logic — better-auth's `auth.api.*` already is + that logic, and threading the app's auth instance through a package helper buys + friction, not safety. The `[body.field]` error-message cleanup stays internal to + the form. +- **The app (and the `starter-auth` template / CLI generator) owns the action + implementations** in `lib/actions/auth.ts`, calling `auth.api.*` directly. +- **`createAuth` auto-adds better-auth's `nextCookies()` plugin as the last plugin**, + so a session cookie set during a server-action call is written into Next's + `cookies()` without every app remembering the one step that makes sign-in persist. +- **Redirect is asymmetric by design:** email actions return an `AuthActionResult` + and the client form redirects (`redirectTo` + `onSuccess`/`onError` preserved), + while social sign-in must navigate away, so its action performs a server-side + `redirect()` to the provider URL. This reflects that OAuth leaves the app and email + does not — not an inconsistency. +- **`createClient` is kept** in the package for client-side session reading + (`useSession`), but the now-unused `auth-client.ts` is dropped from the templates. + +## Considered options + +- **Keep `authClient` for social only** — rejected: a form would need both an action + prop and `authClient`, reintroducing exactly what we removed. +- **Server-side `redirect()` for email too** — rejected: drops the client-controlled + `redirectTo` and the `onSuccess`/`onError` callbacks, and errors still have to come + back as data anyway. +- **A package `createAuthActions(auth)` factory / exported action helpers** — + rejected: shadows better-auth's own API and couples the package to a fragile + `ReturnType` parameter. + +## Consequences + +- Breaking change to `@opensaas/stack-auth/ui`'s form props (`authClient` → per-concern + action props). Released as a **minor** bump: the packages are pre-1.0 (0.x), where a + minor may carry breaking changes by semver convention. The CLI feature-generator and + all three auth examples (`starter-auth`, `auth-demo`, `mcp-demo`) are updated in the + same change, since a green build and a correct scaffolder require it. +- This ADR also folds in the previously-missing `reset-password` page and + `ResetPasswordForm`, completing the forgot-password flow that until now dead-ended + on a 404. diff --git a/examples/auth-demo/app/forgot-password/page.tsx b/examples/auth-demo/app/forgot-password/page.tsx index 7e144f7a..cea91130 100644 --- a/examples/auth-demo/app/forgot-password/page.tsx +++ b/examples/auth-demo/app/forgot-password/page.tsx @@ -1,5 +1,5 @@ import { ForgotPasswordForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { requestPasswordResetAction } from '@/lib/actions/auth' import Link from 'next/link' export default function ForgotPasswordPage() { @@ -12,7 +12,7 @@ export default function ForgotPasswordPage() {
- +
diff --git a/examples/auth-demo/app/reset-password/page.tsx b/examples/auth-demo/app/reset-password/page.tsx new file mode 100644 index 00000000..b9894748 --- /dev/null +++ b/examples/auth-demo/app/reset-password/page.tsx @@ -0,0 +1,32 @@ +import { ResetPasswordForm } from '@opensaas/stack-auth/ui' +import { resetPasswordAction } from '@/lib/actions/auth' +import Link from 'next/link' + +export default async function ResetPasswordPage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }> +}) { + const { token } = await searchParams + + return ( +
+
+
+

Set a New Password

+

Choose a new password for your account

+
+ +
+ + +
+ + Back to sign in + +
+
+
+
+ ) +} diff --git a/examples/auth-demo/app/sign-in/page.tsx b/examples/auth-demo/app/sign-in/page.tsx index 608283b4..7b81bcd2 100644 --- a/examples/auth-demo/app/sign-in/page.tsx +++ b/examples/auth-demo/app/sign-in/page.tsx @@ -1,5 +1,5 @@ import { SignInForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { signInAction } from '@/lib/actions/auth' import Link from 'next/link' export default function SignInPage() { @@ -12,7 +12,7 @@ export default function SignInPage() {
- +
diff --git a/examples/auth-demo/app/sign-up/page.tsx b/examples/auth-demo/app/sign-up/page.tsx index 99eae5c7..fa80579e 100644 --- a/examples/auth-demo/app/sign-up/page.tsx +++ b/examples/auth-demo/app/sign-up/page.tsx @@ -1,5 +1,5 @@ import { SignUpForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { signUpAction } from '@/lib/actions/auth' import Link from 'next/link' export default function SignUpPage() { @@ -13,7 +13,7 @@ export default function SignUpPage() {
{ + try { + await auth.api.signInEmail({ + body: { email: input.email, password: input.password }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Sign in failed') } + } +} + +export async function signUpAction(input: SignUpInput): Promise { + try { + await auth.api.signUpEmail({ + body: { name: input.name, email: input.email, password: input.password }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Sign up failed') } + } +} + +export async function requestPasswordResetAction( + input: RequestPasswordResetInput, +): Promise { + try { + await auth.api.requestPasswordReset({ + body: { email: input.email, redirectTo: '/reset-password' }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Failed to send reset email') } + } +} + +export async function resetPasswordAction(input: ResetPasswordInput): Promise { + try { + await auth.api.resetPassword({ + body: { newPassword: input.password, token: input.token }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Failed to reset password') } + } +} diff --git a/examples/auth-demo/lib/auth-client.ts b/examples/auth-demo/lib/auth-client.ts deleted file mode 100644 index 73e16eb2..00000000 --- a/examples/auth-demo/lib/auth-client.ts +++ /dev/null @@ -1,21 +0,0 @@ -'use client' - -import { createClient } from '@opensaas/stack-auth/client' - -/** - * Better-auth client instance - * Use this in your React components to access auth state and methods - * - * @example - * ```typescript - * import { authClient } from '@/lib/auth-client' - * - * function MyComponent() { - * const { data: session } = authClient.useSession() - * // ... - * } - * ``` - */ -export const authClient = createClient({ - baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003', -}) diff --git a/examples/mcp-demo/app/sign-in/page.tsx b/examples/mcp-demo/app/sign-in/page.tsx index 41a5850a..bcb88f3c 100644 --- a/examples/mcp-demo/app/sign-in/page.tsx +++ b/examples/mcp-demo/app/sign-in/page.tsx @@ -1,5 +1,5 @@ import { SignInForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { signInAction } from '@/lib/actions/auth' import Link from 'next/link' export default function SignInPage() { @@ -12,7 +12,7 @@ export default function SignInPage() {
- +
diff --git a/examples/mcp-demo/lib/actions/auth.ts b/examples/mcp-demo/lib/actions/auth.ts new file mode 100644 index 00000000..cad6eb67 --- /dev/null +++ b/examples/mcp-demo/lib/actions/auth.ts @@ -0,0 +1,28 @@ +'use server' + +import { headers } from 'next/headers' +import { auth } from '@/lib/auth' +import type { AuthActionResult, SignInInput } from '@opensaas/stack-auth/ui' + +/** + * App-owned auth server actions. The pre-built auth forms submit through these + * (calling better-auth's server API directly) instead of the browser calling + * `/api/auth/*`. `createAuth` auto-adds better-auth's `nextCookies` plugin, so + * the session cookie set inside these actions persists. See ADR-0020. + */ + +function errorMessage(err: unknown, fallback: string): string { + return err instanceof Error && err.message ? err.message : fallback +} + +export async function signInAction(input: SignInInput): Promise { + try { + await auth.api.signInEmail({ + body: { email: input.email, password: input.password }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Sign in failed') } + } +} diff --git a/examples/mcp-demo/lib/auth-client.ts b/examples/mcp-demo/lib/auth-client.ts deleted file mode 100644 index aa6d5591..00000000 --- a/examples/mcp-demo/lib/auth-client.ts +++ /dev/null @@ -1,21 +0,0 @@ -'use client' - -import { createClient } from '@opensaas/stack-auth/client' - -/** - * Better-auth client instance - * Use this in your React components to access auth state and methods - * - * @example - * ```typescript - * import { authClient } from '@/lib/auth-client' - * - * function MyComponent() { - * const { data: session } = authClient.useSession() - * // ... - * } - * ``` - */ -export const authClient = createClient({ - baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', -}) diff --git a/examples/starter-auth/app/forgot-password/page.tsx b/examples/starter-auth/app/forgot-password/page.tsx index 60196ca3..75409f83 100644 --- a/examples/starter-auth/app/forgot-password/page.tsx +++ b/examples/starter-auth/app/forgot-password/page.tsx @@ -1,5 +1,5 @@ import { ForgotPasswordForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { requestPasswordResetAction } from '@/lib/actions/auth' import Link from 'next/link' export default function ForgotPasswordPage() { @@ -12,7 +12,7 @@ export default function ForgotPasswordPage() {
- +
diff --git a/examples/starter-auth/app/reset-password/page.tsx b/examples/starter-auth/app/reset-password/page.tsx new file mode 100644 index 00000000..ed1fa661 --- /dev/null +++ b/examples/starter-auth/app/reset-password/page.tsx @@ -0,0 +1,32 @@ +import { ResetPasswordForm } from '@opensaas/stack-auth/ui' +import { resetPasswordAction } from '@/lib/actions/auth' +import Link from 'next/link' + +export default async function ResetPasswordPage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }> +}) { + const { token } = await searchParams + + return ( +
+
+
+

Set a New Password

+

Choose a new password for your account

+
+ +
+ + +
+ + Back to sign in + +
+
+
+
+ ) +} diff --git a/examples/starter-auth/app/sign-in/page.tsx b/examples/starter-auth/app/sign-in/page.tsx index 2219af76..d9166a3a 100644 --- a/examples/starter-auth/app/sign-in/page.tsx +++ b/examples/starter-auth/app/sign-in/page.tsx @@ -1,5 +1,5 @@ import { SignInForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { signInAction } from '@/lib/actions/auth' import Link from 'next/link' export default function SignInPage() { @@ -12,7 +12,7 @@ export default function SignInPage() {
- +
diff --git a/examples/starter-auth/app/sign-up/page.tsx b/examples/starter-auth/app/sign-up/page.tsx index 3659b78c..0749175c 100644 --- a/examples/starter-auth/app/sign-up/page.tsx +++ b/examples/starter-auth/app/sign-up/page.tsx @@ -1,5 +1,5 @@ import { SignUpForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { signUpAction } from '@/lib/actions/auth' import Link from 'next/link' export default function SignUpPage() { @@ -13,7 +13,7 @@ export default function SignUpPage() {
{ + try { + await auth.api.signInEmail({ + body: { email: input.email, password: input.password }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Sign in failed') } + } +} + +export async function signUpAction(input: SignUpInput): Promise { + try { + await auth.api.signUpEmail({ + body: { name: input.name, email: input.email, password: input.password }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Sign up failed') } + } +} + +export async function requestPasswordResetAction( + input: RequestPasswordResetInput, +): Promise { + try { + await auth.api.requestPasswordReset({ + body: { email: input.email, redirectTo: '/reset-password' }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Failed to send reset email') } + } +} + +export async function resetPasswordAction(input: ResetPasswordInput): Promise { + try { + await auth.api.resetPassword({ + body: { newPassword: input.password, token: input.token }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Failed to reset password') } + } +} diff --git a/examples/starter-auth/lib/auth-client.ts b/examples/starter-auth/lib/auth-client.ts deleted file mode 100644 index aa6d5591..00000000 --- a/examples/starter-auth/lib/auth-client.ts +++ /dev/null @@ -1,21 +0,0 @@ -'use client' - -import { createClient } from '@opensaas/stack-auth/client' - -/** - * Better-auth client instance - * Use this in your React components to access auth state and methods - * - * @example - * ```typescript - * import { authClient } from '@/lib/auth-client' - * - * function MyComponent() { - * const { data: session } = authClient.useSession() - * // ... - * } - * ``` - */ -export const authClient = createClient({ - baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', -}) diff --git a/packages/auth/CLAUDE.md b/packages/auth/CLAUDE.md index 252bd65c..84b9564b 100644 --- a/packages/auth/CLAUDE.md +++ b/packages/auth/CLAUDE.md @@ -38,11 +38,13 @@ Auto-generated lists: ### UI (`src/ui/index.ts`) -Pre-built forms (client components): +Pre-built forms (client components). Each takes **server action** props (not an +`authClient`) — see "Auth forms submit through server actions" below and ADR-0020: - `SignInForm` - Email/password + OAuth sign in - `SignUpForm` - Create account with password confirmation - `ForgotPasswordForm` - Request password reset email +- `ResetPasswordForm` - Set a new password from a reset-email token ### Plugins (`src/plugins/index.ts`) @@ -334,18 +336,47 @@ export default config({ }) // 2. Server (lib/auth.ts) -export const auth = createAuth(config) +export const auth = createAuth(config, rawOpensaasContext) // 3. Route (app/api/auth/[...all]/route.ts) export { GET, POST } from '@/lib/auth' -// 4. Client (lib/auth-client.ts) -export const authClient = createClient({ baseURL: process.env.NEXT_PUBLIC_APP_URL }) +// 4. Server actions (lib/actions/auth.ts) — the forms submit through these +'use server' +import { auth } from '@/lib/auth' +import type { AuthActionResult, SignInInput } from '@opensaas/stack-auth/ui' +export async function signInAction(input: SignInInput): Promise { + try { + await auth.api.signInEmail({ body: input, headers: await headers() }) + return { success: true } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Sign in failed' } + } +} // 5. UI (app/sign-in/page.tsx) - + ``` +### Auth forms submit through server actions + +The pre-built forms take app-owned `'use server'` action props instead of a browser +`authClient`, so the auth network surface stays server-side (no `/api/auth/*` call from +the browser). `createAuth` auto-adds better-auth's `nextCookies` plugin (as the last +plugin) so a session cookie set inside a server action persists. The action props are: + +- `SignInForm`: `signInAction`, optional `signInSocialAction` +- `SignUpForm`: `signUpAction`, optional `signInSocialAction` +- `ForgotPasswordForm`: `requestPasswordResetAction` +- `ResetPasswordForm`: `resetPasswordAction` + a `token` prop (the page reads it from + `searchParams.token`) + +Email actions return `AuthActionResult` and the form redirects client-side; social +sign-in redirects server-side to the provider. The package exports the contract types +(`AuthActionResult`, `SignInInput`, `SignUpInput`, `RequestPasswordResetInput`, +`ResetPasswordInput`, and the action aliases). `createClient` is unchanged for +client-side session reading (`useSession`). See ADR-0020 and `examples/starter-auth`. + ### Access Control with Session ```typescript @@ -376,8 +407,12 @@ authPlugin({ } }) -// UI - +// UI — pass the redirecting social action to enable the provider buttons + ``` ## Type Safety diff --git a/packages/auth/README.md b/packages/auth/README.md index 8c064d6d..1e7d786f 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -77,17 +77,30 @@ export const POST = auth.handler export { GET, POST } from '@/lib/auth' ``` -### 5. Create Auth Client +### 5. Create Auth Server Actions -```typescript -// lib/auth-client.ts -'use client' - -import { createClient } from '@opensaas/stack-auth/client' +The pre-built forms submit through app-owned server actions (calling better-auth's +server API directly) instead of a browser client. `createAuth` auto-adds better-auth's +`nextCookies` plugin, so the session cookie set inside these actions persists. -export const authClient = createClient({ - baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', -}) +```typescript +// lib/actions/auth.ts +'use server' + +import { headers } from 'next/headers' +import { auth } from '@/lib/auth' +import type { AuthActionResult, SignInInput } from '@opensaas/stack-auth/ui' + +export async function signInAction(input: SignInInput): Promise { + try { + await auth.api.signInEmail({ body: input, headers: await headers() }) + return { success: true } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : 'Sign in failed' } + } +} +// ...and signUpAction / requestPasswordResetAction / resetPasswordAction — see +// examples/starter-auth/lib/actions/auth.ts for the full set. ``` ### 6. Add Sign In Page @@ -95,12 +108,12 @@ export const authClient = createClient({ ```typescript // app/sign-in/page.tsx import { SignInForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { signInAction } from '@/lib/actions/auth' export default function SignInPage() { return (
- +
) } @@ -238,14 +251,18 @@ the full migrator walkthrough and a Schema-parity (clean-diff) check. ## UI Components +Each form takes app-owned server action props (defined in `lib/actions/auth.ts`), +not an `authClient`. See ADR-0020 and `examples/starter-auth`. + ### SignInForm ```typescript import { SignInForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { signInAction, signInSocialAction } from '@/lib/actions/auth' console.log('Account created!')} @@ -272,14 +289,31 @@ import { authClient } from '@/lib/auth-client' ```typescript import { ForgotPasswordForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { requestPasswordResetAction } from '@/lib/actions/auth' console.log('Reset email sent!')} /> ``` +### ResetPasswordForm + +```typescript +// app/reset-password/page.tsx +import { ResetPasswordForm } from '@opensaas/stack-auth/ui' +import { resetPasswordAction } from '@/lib/actions/auth' + +export default async function ResetPasswordPage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }> +}) { + const { token } = await searchParams + return +} +``` + ## Auto-Generated Lists The following lists are automatically created when you use `authPlugin()`: @@ -404,7 +438,19 @@ access: { ## Client-Side Hooks -Use better-auth hooks in your React components: +The auth forms no longer need a browser client (they use server actions). To read the +session on the client, create a client yourself with `createClient` and use its hooks: + +```typescript +// lib/auth-client.ts +'use client' + +import { createClient } from '@opensaas/stack-auth/client' + +export const authClient = createClient({ + baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', +}) +``` ```typescript 'use client' diff --git a/packages/auth/src/server/index.ts b/packages/auth/src/server/index.ts index 061229f4..3f019ee7 100644 --- a/packages/auth/src/server/index.ts +++ b/packages/auth/src/server/index.ts @@ -1,5 +1,6 @@ import { betterAuth } from 'better-auth' import { prismaAdapter } from 'better-auth/adapters/prisma' +import { nextCookies } from 'better-auth/next-js' import type { BetterAuthOptions } from 'better-auth' import type { OpenSaasConfig, AccessContext } from '@opensaas/stack-core' import type { DatabaseConfig } from '@opensaas/stack-core/internal' @@ -130,8 +131,13 @@ export function createAuth( } : undefined, - // Pass through any additional Better Auth plugins - plugins: authConfig.betterAuthPlugins || [], + // Pass through any additional Better Auth plugins, then append + // nextCookies LAST so it can write the Set-Cookie headers produced by + // any auth.api.* call made inside a Next.js server action into Next's + // cookie store. This is what makes the server-action auth forms (which + // call auth.api.signInEmail/signUpEmail/etc. server-side) actually + // persist a session. It must be the final plugin in the array. + plugins: [...(authConfig.betterAuthPlugins || []), nextCookies()], } authInstance = betterAuth(betterAuthConfig) diff --git a/packages/auth/src/ui/components/ForgotPasswordForm.tsx b/packages/auth/src/ui/components/ForgotPasswordForm.tsx index df5a18ed..1354c6d3 100644 --- a/packages/auth/src/ui/components/ForgotPasswordForm.tsx +++ b/packages/auth/src/ui/components/ForgotPasswordForm.tsx @@ -1,14 +1,15 @@ 'use client' import React, { useState } from 'react' -import type { createAuthClient } from 'better-auth/react' +import { cleanAuthErrorMessage } from '../lib/clean-error-message.js' +import type { RequestPasswordResetAction } from '../types.js' export type ForgotPasswordFormProps = { /** - * Better-auth client instance - * Created with createAuthClient from better-auth/react + * Server action that requests a password-reset email. + * Define it in your app (`'use server'`) against your own auth instance. */ - authClient: ReturnType + requestPasswordResetAction: RequestPasswordResetAction /** * Custom CSS class for the form container */ @@ -27,18 +28,21 @@ export type ForgotPasswordFormProps = { * Forgot password form component * Allows users to request a password reset email * + * Submits through an app-owned server action rather than calling the auth API + * from the browser. See the "Auth action" contract in `@opensaas/stack-auth/ui`. + * * @example * ```typescript * import { ForgotPasswordForm } from '@opensaas/stack-auth/ui' - * import { authClient } from '@/lib/auth-client' + * import { requestPasswordResetAction } from '@/lib/actions/auth' * * export default function ForgotPasswordPage() { - * return + * return * } * ``` */ export function ForgotPasswordForm({ - authClient, + requestPasswordResetAction, className = '', onSuccess, onError, @@ -55,19 +59,19 @@ export function ForgotPasswordForm({ setLoading(true) try { - const result = await authClient.requestPasswordReset({ - email, - redirectTo: '/reset-password', - }) + const result = await requestPasswordResetAction({ email }) - if (result.error) { - throw new Error(result.error.message) + if (!result.success) { + throw new Error(cleanAuthErrorMessage(result.error, 'Failed to send reset email')) } setSuccess(true) onSuccess?.() } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to send reset email' + const message = cleanAuthErrorMessage( + err instanceof Error ? err.message : undefined, + 'Failed to send reset email', + ) setError(message) onError?.(err instanceof Error ? err : new Error(message)) } finally { diff --git a/packages/auth/src/ui/components/ResetPasswordForm.tsx b/packages/auth/src/ui/components/ResetPasswordForm.tsx new file mode 100644 index 00000000..893c9170 --- /dev/null +++ b/packages/auth/src/ui/components/ResetPasswordForm.tsx @@ -0,0 +1,182 @@ +'use client' + +import React, { useState } from 'react' +import { useRouter } from 'next/navigation.js' +import { cleanAuthErrorMessage } from '../lib/clean-error-message.js' +import type { ResetPasswordAction } from '../types.js' + +export type ResetPasswordFormProps = { + /** + * Server action that completes the password reset. + * Define it in your app (`'use server'`) against your own auth instance. + */ + resetPasswordAction: ResetPasswordAction + /** + * The reset token from the email link. The page reads it from + * `searchParams.token` and passes it in. When empty, the form renders an + * "invalid or expired link" state instead of a password form. + */ + token: string + /** + * URL to redirect to after a successful reset + * @default '/sign-in' + */ + redirectTo?: string + /** + * Require password confirmation + * @default true + */ + requirePasswordConfirmation?: boolean + /** + * Custom CSS class for the form container + */ + className?: string + /** + * Callback when the reset succeeds + */ + onSuccess?: () => void + /** + * Callback when the reset fails + */ + onError?: (error: Error) => void +} + +/** + * Reset password form component + * Completes a password reset using the token from the reset email. + * + * Submits through an app-owned server action rather than calling the auth API + * from the browser. See the "Auth action" contract in `@opensaas/stack-auth/ui`. + * + * @example + * ```typescript + * import { ResetPasswordForm } from '@opensaas/stack-auth/ui' + * import { resetPasswordAction } from '@/lib/actions/auth' + * + * export default async function ResetPasswordPage({ + * searchParams, + * }: { + * searchParams: Promise<{ token?: string }> + * }) { + * const { token } = await searchParams + * return + * } + * ``` + */ +export function ResetPasswordForm({ + resetPasswordAction, + token, + redirectTo = '/sign-in', + requirePasswordConfirmation = true, + className = '', + onSuccess, + onError, +}: ResetPasswordFormProps) { + const router = useRouter() + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + // Guard: no token means the user hit this page directly or followed a + // malformed/expired link. Don't render a form that can't succeed. + if (!token) { + return ( +
+

Reset Password

+
+ This password reset link is invalid or has expired. Please request a new one. +
+
+ ) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + + if (requirePasswordConfirmation && password !== confirmPassword) { + setError('Passwords do not match') + return + } + + setLoading(true) + + try { + const result = await resetPasswordAction({ token, password }) + + if (!result.success) { + throw new Error(cleanAuthErrorMessage(result.error, 'Failed to reset password')) + } + + if (onSuccess) { + onSuccess() + } else { + router.push(redirectTo) + } + } catch (err) { + const message = cleanAuthErrorMessage( + err instanceof Error ? err.message : undefined, + 'Failed to reset password', + ) + setError(message) + onError?.(err instanceof Error ? err : new Error(message)) + } finally { + setLoading(false) + } + } + + return ( +
+

Reset Password

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setPassword((e.target as HTMLInputElement).value)} + required + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={loading} + /> +
+ + {requirePasswordConfirmation && ( +
+ + setConfirmPassword((e.target as HTMLInputElement).value)} + required + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={loading} + /> +
+ )} + + +
+
+ ) +} diff --git a/packages/auth/src/ui/components/SignInForm.tsx b/packages/auth/src/ui/components/SignInForm.tsx index dd3598ad..baa9b704 100644 --- a/packages/auth/src/ui/components/SignInForm.tsx +++ b/packages/auth/src/ui/components/SignInForm.tsx @@ -2,22 +2,27 @@ import React, { useState } from 'react' import { useRouter } from 'next/navigation.js' -import type { createAuthClient } from 'better-auth/react' +import { cleanAuthErrorMessage } from '../lib/clean-error-message.js' +import type { SignInAction, SignInSocialAction } from '../types.js' export type SignInFormProps = { /** - * Better-auth client instance - * Created with createAuthClient from better-auth/react - * Pass your client from lib/auth-client.ts + * Server action that signs a user in with email + password. + * Define it in your app (`'use server'`) against your own auth instance. */ - authClient: ReturnType + signInAction: SignInAction + /** + * Server action that starts an OAuth sign-in and redirects to the provider. + * Required to render the social provider buttons; omit to hide them. + */ + signInSocialAction?: SignInSocialAction /** * URL to redirect to after successful sign in * @default '/' */ redirectTo?: string /** - * Show OAuth provider buttons + * Show OAuth provider buttons (requires `signInSocialAction`) * @default true */ showSocialProviders?: boolean @@ -44,18 +49,28 @@ export type SignInFormProps = { * Sign in form component * Provides email/password sign in and OAuth provider buttons * + * Submits through app-owned server actions rather than calling the auth API + * from the browser. See the "Auth action" contract in `@opensaas/stack-auth/ui`. + * * @example * ```typescript * import { SignInForm } from '@opensaas/stack-auth/ui' - * import { authClient } from '@/lib/auth-client' + * import { signInAction, signInSocialAction } from '@/lib/actions/auth' * * export default function SignInPage() { - * return + * return ( + * + * ) * } * ``` */ export function SignInForm({ - authClient, + signInAction, + signInSocialAction, redirectTo = '/', showSocialProviders = true, socialProviders = ['github', 'google'], @@ -75,14 +90,10 @@ export function SignInForm({ setLoading(true) try { - const result = await authClient.signIn.email({ - email, - password, - callbackURL: redirectTo, - }) - - if (result.error) { - throw new Error(result.error.message) + const result = await signInAction({ email, password }) + + if (!result.success) { + throw new Error(cleanAuthErrorMessage(result.error, 'Sign in failed')) } // If onSuccess is provided, call it. Otherwise, automatically redirect @@ -92,7 +103,10 @@ export function SignInForm({ router.push(redirectTo) } } catch (err) { - const message = err instanceof Error ? err.message : 'Sign in failed' + const message = cleanAuthErrorMessage( + err instanceof Error ? err.message : undefined, + 'Sign in failed', + ) setError(message) onError?.(err instanceof Error ? err : new Error(message)) } finally { @@ -101,25 +115,28 @@ export function SignInForm({ } const handleSocialSignIn = async (provider: string) => { + if (!signInSocialAction) return setError('') setLoading(true) try { - await authClient.signIn.social({ - provider, - callbackURL: redirectTo, - }) - // Social sign-in handles its own redirect via OAuth flow - // Only call onSuccess if provided + // The action performs a server-side redirect to the provider, so on + // success control does not return here. + await signInSocialAction(provider) onSuccess?.() } catch (err) { - const message = err instanceof Error ? err.message : 'Sign in failed' + const message = cleanAuthErrorMessage( + err instanceof Error ? err.message : undefined, + 'Sign in failed', + ) setError(message) onError?.(err instanceof Error ? err : new Error(message)) setLoading(false) } } + const canShowSocial = showSocialProviders && socialProviders.length > 0 && !!signInSocialAction + return (

Sign In

@@ -170,7 +187,7 @@ export function SignInForm({ - {showSocialProviders && socialProviders.length > 0 && ( + {canShowSocial && ( <>
diff --git a/packages/auth/src/ui/components/SignUpForm.tsx b/packages/auth/src/ui/components/SignUpForm.tsx index 0ef6c11b..0fa304ba 100644 --- a/packages/auth/src/ui/components/SignUpForm.tsx +++ b/packages/auth/src/ui/components/SignUpForm.tsx @@ -2,21 +2,27 @@ import React, { useState } from 'react' import { useRouter } from 'next/navigation.js' -import type { createAuthClient } from 'better-auth/react' +import { cleanAuthErrorMessage } from '../lib/clean-error-message.js' +import type { SignUpAction, SignInSocialAction } from '../types.js' export type SignUpFormProps = { /** - * Better-auth client instance - * Created with createAuthClient from better-auth/react + * Server action that creates an account with email + password. + * Define it in your app (`'use server'`) against your own auth instance. */ - authClient: ReturnType + signUpAction: SignUpAction + /** + * Server action that starts an OAuth sign-in and redirects to the provider. + * Required to render the social provider buttons; omit to hide them. + */ + signInSocialAction?: SignInSocialAction /** * URL to redirect to after successful sign up * @default '/' */ redirectTo?: string /** - * Show OAuth provider buttons + * Show OAuth provider buttons (requires `signInSocialAction`) * @default true */ showSocialProviders?: boolean @@ -48,18 +54,22 @@ export type SignUpFormProps = { * Sign up form component * Provides email/password registration and OAuth provider buttons * + * Submits through app-owned server actions rather than calling the auth API + * from the browser. See the "Auth action" contract in `@opensaas/stack-auth/ui`. + * * @example * ```typescript * import { SignUpForm } from '@opensaas/stack-auth/ui' - * import { authClient } from '@/lib/auth-client' + * import { signUpAction, signInSocialAction } from '@/lib/actions/auth' * * export default function SignUpPage() { - * return + * return * } * ``` */ export function SignUpForm({ - authClient, + signUpAction, + signInSocialAction, redirectTo = '/', showSocialProviders = true, socialProviders = ['github', 'google'], @@ -89,18 +99,10 @@ export function SignUpForm({ setLoading(true) try { - const result = await authClient.signUp.email({ - email, - password, - name, - callbackURL: redirectTo, - }) - - if (result.error) { - // Strip [body.field] prefixes from better-call validation errors for user-friendly display - const rawMessage = result.error.message ?? 'Sign up failed' - const cleanMessage = rawMessage.replace(/\[body\.\w+\]\s*/g, '').trim() - throw new Error(cleanMessage) + const result = await signUpAction({ name, email, password }) + + if (!result.success) { + throw new Error(cleanAuthErrorMessage(result.error, 'Sign up failed')) } // If onSuccess is provided, call it. Otherwise, automatically redirect @@ -110,7 +112,10 @@ export function SignUpForm({ router.push(redirectTo) } } catch (err) { - const message = err instanceof Error ? err.message : 'Sign up failed' + const message = cleanAuthErrorMessage( + err instanceof Error ? err.message : undefined, + 'Sign up failed', + ) setError(message) onError?.(err instanceof Error ? err : new Error(message)) } finally { @@ -119,25 +124,28 @@ export function SignUpForm({ } const handleSocialSignUp = async (provider: string) => { + if (!signInSocialAction) return setError('') setLoading(true) try { - await authClient.signIn.social({ - provider, - callbackURL: redirectTo, - }) - // Social sign-in handles its own redirect via OAuth flow - // Only call onSuccess if provided + // The action performs a server-side redirect to the provider, so on + // success control does not return here. + await signInSocialAction(provider) onSuccess?.() } catch (err) { - const message = err instanceof Error ? err.message : 'Sign up failed' + const message = cleanAuthErrorMessage( + err instanceof Error ? err.message : undefined, + 'Sign up failed', + ) setError(message) onError?.(err instanceof Error ? err : new Error(message)) setLoading(false) } } + const canShowSocial = showSocialProviders && socialProviders.length > 0 && !!signInSocialAction + return (

Sign Up

@@ -220,7 +228,7 @@ export function SignUpForm({ - {showSocialProviders && socialProviders.length > 0 && ( + {canShowSocial && ( <>
diff --git a/packages/auth/src/ui/index.ts b/packages/auth/src/ui/index.ts index 5b6694bb..d73e5607 100644 --- a/packages/auth/src/ui/index.ts +++ b/packages/auth/src/ui/index.ts @@ -1,7 +1,24 @@ export { SignInForm } from './components/SignInForm.js' export { SignUpForm } from './components/SignUpForm.js' export { ForgotPasswordForm } from './components/ForgotPasswordForm.js' +export { ResetPasswordForm } from './components/ResetPasswordForm.js' export type { SignInFormProps } from './components/SignInForm.js' export type { SignUpFormProps } from './components/SignUpForm.js' export type { ForgotPasswordFormProps } from './components/ForgotPasswordForm.js' +export type { ResetPasswordFormProps } from './components/ResetPasswordForm.js' + +// Auth action contract types — the agreement between the forms and the +// app-owned server actions they invoke. +export type { + AuthActionResult, + SignInInput, + SignUpInput, + RequestPasswordResetInput, + ResetPasswordInput, + SignInAction, + SignUpAction, + RequestPasswordResetAction, + ResetPasswordAction, + SignInSocialAction, +} from './types.js' diff --git a/packages/auth/src/ui/lib/clean-error-message.ts b/packages/auth/src/ui/lib/clean-error-message.ts new file mode 100644 index 00000000..d785a899 --- /dev/null +++ b/packages/auth/src/ui/lib/clean-error-message.ts @@ -0,0 +1,21 @@ +/** + * Strip better-call's `[body.field]` validation prefixes from an auth error + * message so forms can display something user-friendly. + * + * Better-auth surfaces validation errors like `[body.password] Password is too + * short`. This removes those bracketed prefixes and normalises whitespace, + * falling back to a default when the message is empty or missing. + * + * Internal to the auth forms — not part of the package's public contract. + */ +export function cleanAuthErrorMessage( + message: string | null | undefined, + fallback = 'Something went wrong', +): string { + if (!message) return fallback + const cleaned = message + .replace(/\[body\.\w+\]\s*/g, '') + .replace(/\s+/g, ' ') + .trim() + return cleaned || fallback +} diff --git a/packages/auth/src/ui/types.ts b/packages/auth/src/ui/types.ts new file mode 100644 index 00000000..69369ff8 --- /dev/null +++ b/packages/auth/src/ui/types.ts @@ -0,0 +1,49 @@ +/** + * Contract types shared between the pre-built auth forms and the app-owned + * server actions they invoke. + * + * The forms live in this package but never own an `auth` instance. Instead each + * form receives **Auth actions** as props — `'use server'` functions the app + * defines against its own better-auth instance. These types are the agreement + * between the two: the app implements actions matching them, the forms call + * actions matching them. + */ + +/** + * The result an email/password (non-redirecting) auth action returns. + * Success carries no payload; failure carries a display-ready message. + */ +export type AuthActionResult = { success: true } | { success: false; error: string } + +/** Input to the sign-in action. */ +export type SignInInput = { email: string; password: string } + +/** Input to the sign-up action. */ +export type SignUpInput = { name: string; email: string; password: string } + +/** Input to the request-password-reset action. */ +export type RequestPasswordResetInput = { email: string } + +/** Input to the reset-password action. */ +export type ResetPasswordInput = { token: string; password: string } + +/** Signs a user in with email + password. */ +export type SignInAction = (input: SignInInput) => Promise + +/** Creates an account with email + password. */ +export type SignUpAction = (input: SignUpInput) => Promise + +/** Requests a password-reset email. */ +export type RequestPasswordResetAction = ( + input: RequestPasswordResetInput, +) => Promise + +/** Completes a password reset using a token from the reset email. */ +export type ResetPasswordAction = (input: ResetPasswordInput) => Promise + +/** + * Starts an OAuth sign-in for the given provider. Unlike the email actions this + * one navigates away (it performs a server-side redirect to the provider), so + * it resolves to `void` and never returns a result to the form. + */ +export type SignInSocialAction = (provider: string) => Promise diff --git a/packages/auth/tests/clean-error-message.test.ts b/packages/auth/tests/clean-error-message.test.ts new file mode 100644 index 00000000..68a1b1ef --- /dev/null +++ b/packages/auth/tests/clean-error-message.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { cleanAuthErrorMessage } from '../src/ui/lib/clean-error-message.js' + +describe('cleanAuthErrorMessage', () => { + it('strips a single [body.field] prefix', () => { + expect(cleanAuthErrorMessage('[body.password] Password too short')).toBe('Password too short') + }) + + it('strips multiple [body.field] prefixes', () => { + expect(cleanAuthErrorMessage('[body.email] invalid [body.password] weak')).toBe('invalid weak') + }) + + it('leaves a clean message untouched', () => { + expect(cleanAuthErrorMessage('Invalid email or password')).toBe('Invalid email or password') + }) + + it('falls back to a default when the message is empty or nullish', () => { + expect(cleanAuthErrorMessage('', 'Sign in failed')).toBe('Sign in failed') + expect(cleanAuthErrorMessage(undefined, 'Sign in failed')).toBe('Sign in failed') + }) + + it('uses a generic default when none is provided', () => { + expect(cleanAuthErrorMessage(undefined)).toBe('Something went wrong') + }) + + it('trims surrounding whitespace left after stripping', () => { + expect(cleanAuthErrorMessage(' [body.name] Name is required ')).toBe('Name is required') + }) +}) diff --git a/packages/cli/src/mcp/lib/generators/feature-generator.ts b/packages/cli/src/mcp/lib/generators/feature-generator.ts index da48c2af..b8343250 100644 --- a/packages/cli/src/mcp/lib/generators/feature-generator.ts +++ b/packages/cli/src/mcp/lib/generators/feature-generator.ts @@ -204,18 +204,102 @@ export const POST = auth.handler`, content: `export { GET, POST } from '@/lib/auth'`, }) - // Auth client + // Auth server actions. The forms submit through these app-owned + // 'use server' actions (calling auth.api.* directly) instead of the + // browser calling /api/auth/*. createAuth auto-adds better-auth's + // nextCookies plugin, so the session cookie set inside these actions + // persists. See ADR-0020 and the "Auth action" contract. + const authActionTypeImports = ['AuthActionResult', 'SignInInput'] + if (hasPassword) { + authActionTypeImports.push('SignUpInput', 'RequestPasswordResetInput', 'ResetPasswordInput') + } + + const socialActionSnippet = hasOAuth + ? ` + +// OAuth navigates away from the app, so this action performs a server-side +// redirect to the provider (rather than returning a result to the form). +export async function signInSocialAction(provider: string): Promise { + const response = await auth.api.signInSocial({ + body: { provider, callbackURL: '/admin' }, + headers: await headers(), + }) + if (response && 'url' in response && response.url) { + redirect(response.url) + } +}` + : '' + + const passwordActionSnippets = hasPassword + ? ` + +export async function signUpAction(input: SignUpInput): Promise { + try { + await auth.api.signUpEmail({ + body: { name: input.name, email: input.email, password: input.password }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Sign up failed') } + } +} + +export async function requestPasswordResetAction( + input: RequestPasswordResetInput, +): Promise { + try { + await auth.api.requestPasswordReset({ + body: { email: input.email, redirectTo: '/reset-password' }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Failed to send reset email') } + } +} + +export async function resetPasswordAction(input: ResetPasswordInput): Promise { + try { + await auth.api.resetPassword({ + body: { newPassword: input.password, token: input.token }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Failed to reset password') } + } +}` + : '' + files.push({ - path: 'lib/auth-client.ts', + path: 'lib/actions/auth.ts', language: 'typescript', - description: 'Client-side Better-auth instance for React components', - content: `'use client' + description: 'App-owned server actions the auth forms submit through', + content: `'use server' + +import { headers } from 'next/headers' +${hasOAuth ? "import { redirect } from 'next/navigation'\n" : ''}import { auth } from '@/lib/auth' +import type { + ${authActionTypeImports.join(',\n ')}, +} from '@opensaas/stack-auth/ui' -import { createClient } from '@opensaas/stack-auth/client' +function errorMessage(err: unknown, fallback: string): string { + return err instanceof Error && err.message ? err.message : fallback +} -export const authClient = createClient({ - baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', -})`, +export async function signInAction(input: SignInInput): Promise { + try { + await auth.api.signInEmail({ + body: { email: input.email, password: input.password }, + headers: await headers(), + }) + return { success: true } + } catch (err) { + return { success: false, error: errorMessage(err, 'Sign in failed') } + } +}${passwordActionSnippets}${socialActionSnippet} +`, }) // Sign-in page @@ -224,7 +308,7 @@ export const authClient = createClient({ language: 'tsx', description: 'Sign-in page using the pre-built SignInForm', content: `import { SignInForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { signInAction${hasOAuth ? ', signInSocialAction' : ''} } from '@/lib/actions/auth' import Link from 'next/link' export default function SignInPage() { @@ -233,15 +317,20 @@ export default function SignInPage() {

Sign In

${ hasPassword - ? `
- Don't have an account?{' '} - Sign up + ? `
+
+ Forgot your password? +
+
+ Don't have an account?{' '} + Sign up +
` : '' } @@ -258,14 +347,67 @@ export default function SignInPage() { language: 'tsx', description: 'Sign-up page using the pre-built SignUpForm', content: `import { SignUpForm } from '@opensaas/stack-auth/ui' -import { authClient } from '@/lib/auth-client' +import { signUpAction${hasOAuth ? ', signInSocialAction' : ''} } from '@/lib/actions/auth' export default function SignUpPage() { return (

Create Account

- + +
+
+ ) +}`, + }) + + // Forgot-password page + files.push({ + path: 'app/forgot-password/page.tsx', + language: 'tsx', + description: 'Forgot-password page using the pre-built ForgotPasswordForm', + content: `import { ForgotPasswordForm } from '@opensaas/stack-auth/ui' +import { requestPasswordResetAction } from '@/lib/actions/auth' +import Link from 'next/link' + +export default function ForgotPasswordPage() { + return ( +
+
+

Forgot Password

+ +
+ Back to sign in +
+
+
+ ) +}`, + }) + + // Reset-password page (target of the reset email link) + files.push({ + path: 'app/reset-password/page.tsx', + language: 'tsx', + description: 'Reset-password page using the pre-built ResetPasswordForm', + content: `import { ResetPasswordForm } from '@opensaas/stack-auth/ui' +import { resetPasswordAction } from '@/lib/actions/auth' + +export default async function ResetPasswordPage({ + searchParams, +}: { + searchParams: Promise<{ token?: string }> +}) { + const { token } = await searchParams + return ( +
+
+

Reset Password

+
)