diff --git a/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.js b/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.js index a3c4307327b..cdba8cbb17a 100644 --- a/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.js +++ b/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.js @@ -1,13 +1,9 @@ import * as React from 'react'; import { AppProvider, SignInPage } from '@toolpad/core'; -import { createTheme } from '@mui/material/styles'; -import { useColorSchemeShim } from 'docs/src/modules/components/ThemeContext'; -import { getDesignTokens } from './brandingTheme'; const providers = [ { id: 'github', name: 'GitHub' }, { id: 'google', name: 'Google' }, - { id: 'credentials', name: 'Email and Password' }, ]; // preview-start @@ -34,20 +30,9 @@ const signIn = async (provider) => { }; export default function BrandingSignInPage() { - const { mode, systemMode } = useColorSchemeShim(); - const calculatedMode = (mode === 'system' ? systemMode : mode) ?? 'light'; - const brandingDesignTokens = getDesignTokens(calculatedMode); - const THEME = createTheme({ - ...brandingDesignTokens, - palette: { - ...brandingDesignTokens.palette, - mode: calculatedMode, - }, - }); - return ( // preview-start - + // preview-end diff --git a/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.tsx b/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.tsx index 23ba97e0e02..1b896203b9a 100644 --- a/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.tsx +++ b/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.tsx @@ -1,13 +1,9 @@ import * as React from 'react'; import { AuthProvider, AppProvider, SignInPage } from '@toolpad/core'; -import { createTheme } from '@mui/material/styles'; -import { useColorSchemeShim } from 'docs/src/modules/components/ThemeContext'; -import { getDesignTokens } from './brandingTheme'; const providers = [ { id: 'github', name: 'GitHub' }, { id: 'google', name: 'Google' }, - { id: 'credentials', name: 'Email and Password' }, ]; // preview-start const BRANDING = { @@ -33,20 +29,9 @@ const signIn: (provider: AuthProvider) => void = async (provider) => { }; export default function BrandingSignInPage() { - const { mode, systemMode } = useColorSchemeShim(); - const calculatedMode = (mode === 'system' ? systemMode : mode) ?? 'light'; - const brandingDesignTokens = getDesignTokens(calculatedMode); - const THEME = createTheme({ - ...brandingDesignTokens, - palette: { - ...brandingDesignTokens.palette, - mode: calculatedMode, - }, - }); - return ( // preview-start - + // preview-end diff --git a/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.tsx.preview b/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.tsx.preview index e4142f33849..d63aafc9b64 100644 --- a/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.tsx.preview +++ b/docs/data/toolpad/core/components/sign-in-page/BrandingSignInPage.tsx.preview @@ -11,6 +11,6 @@ const BRANDING = { // ... - + \ No newline at end of file diff --git a/docs/data/toolpad/core/components/sign-in-page/ThemeSignInPage.js b/docs/data/toolpad/core/components/sign-in-page/ThemeSignInPage.js new file mode 100644 index 00000000000..d944150302f --- /dev/null +++ b/docs/data/toolpad/core/components/sign-in-page/ThemeSignInPage.js @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { AppProvider, SignInPage } from '@toolpad/core'; +import { createTheme } from '@mui/material/styles'; +import { useColorSchemeShim } from 'docs/src/modules/components/ThemeContext'; +import { getDesignTokens } from './brandingTheme'; + +const providers = [ + { id: 'github', name: 'GitHub' }, + { id: 'google', name: 'Google' }, + { id: 'credentials', name: 'Email and Password' }, +]; + +const signIn = async (provider) => { + const promise = new Promise((resolve) => { + setTimeout(() => { + console.log(`Sign in with ${provider.id}`); + resolve(); + }, 500); + }); + return promise; +}; + +export default function ThemeSignInPage() { + const { mode, systemMode } = useColorSchemeShim(); + const calculatedMode = (mode === 'system' ? systemMode : mode) ?? 'light'; + const brandingDesignTokens = getDesignTokens(calculatedMode); + // preview-start + const THEME = createTheme({ + ...brandingDesignTokens, + palette: { + ...brandingDesignTokens.palette, + mode: calculatedMode, + }, + }); + // preview-end + + return ( + // preview-start + + + + // preview-end + ); +} diff --git a/docs/data/toolpad/core/components/sign-in-page/ThemeSignInPage.tsx b/docs/data/toolpad/core/components/sign-in-page/ThemeSignInPage.tsx new file mode 100644 index 00000000000..173abd66eaa --- /dev/null +++ b/docs/data/toolpad/core/components/sign-in-page/ThemeSignInPage.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { AuthProvider, AppProvider, SignInPage } from '@toolpad/core'; +import { createTheme } from '@mui/material/styles'; +import { useColorSchemeShim } from 'docs/src/modules/components/ThemeContext'; +import { getDesignTokens } from './brandingTheme'; + +const providers = [ + { id: 'github', name: 'GitHub' }, + { id: 'google', name: 'Google' }, + { id: 'credentials', name: 'Email and Password' }, +]; + +const signIn: (provider: AuthProvider) => void = async (provider) => { + const promise = new Promise((resolve) => { + setTimeout(() => { + console.log(`Sign in with ${provider.id}`); + resolve(); + }, 500); + }); + return promise; +}; + +export default function ThemeSignInPage() { + const { mode, systemMode } = useColorSchemeShim(); + const calculatedMode = (mode === 'system' ? systemMode : mode) ?? 'light'; + const brandingDesignTokens = getDesignTokens(calculatedMode); + // preview-start + const THEME = createTheme({ + ...brandingDesignTokens, + palette: { + ...brandingDesignTokens.palette, + mode: calculatedMode, + }, + }); + // preview-end + + return ( + // preview-start + + + + // preview-end + ); +} diff --git a/docs/data/toolpad/core/components/sign-in-page/ThemeSignInPage.tsx.preview b/docs/data/toolpad/core/components/sign-in-page/ThemeSignInPage.tsx.preview new file mode 100644 index 00000000000..01eecbdf4fe --- /dev/null +++ b/docs/data/toolpad/core/components/sign-in-page/ThemeSignInPage.tsx.preview @@ -0,0 +1,13 @@ +const THEME = createTheme({ + ...brandingDesignTokens, + palette: { + ...brandingDesignTokens.palette, + mode: calculatedMode, + }, +}); + +// ... + + + + \ No newline at end of file diff --git a/docs/data/toolpad/core/components/sign-in-page/sign-in-page.md b/docs/data/toolpad/core/components/sign-in-page/sign-in-page.md index 382691b45b1..79ad9a4d13b 100644 --- a/docs/data/toolpad/core/components/sign-in-page/sign-in-page.md +++ b/docs/data/toolpad/core/components/sign-in-page/sign-in-page.md @@ -132,11 +132,17 @@ If you're using the default [Next.js app directory example](https://github.com/m ## Customization -### Theme and Branding +### Branding -Through the `branding` and `theme` props in the [AppProvider](https://mui.com/toolpad/core/react-app-provider/), the `SignInPage` can be customized to match your own styles. +You can add your own branding elements to the `SignInPage` through the `branding` prop in the [AppProvider](https://mui.com/toolpad/core/react-app-provider/) -{{"demo": "BrandingSignInPage.js", "iframe": true, "height": 700 }} +{{"demo": "BrandingSignInPage.js", "iframe": true, "height": 360 }} + +### Theme + +Through the `theme` prop in the [AppProvider](https://mui.com/toolpad/core/react-app-provider/), the `SignInPage` can be deeply customized to match any theme + +{{"demo": "ThemeSignInPage.js", "iframe": true, "height": 700 }} ### Components diff --git a/docs/pages/toolpad/core/api/sign-in-page.json b/docs/pages/toolpad/core/api/sign-in-page.json index 54dbfb38e69..ac51a1fbbd7 100644 --- a/docs/pages/toolpad/core/api/sign-in-page.json +++ b/docs/pages/toolpad/core/api/sign-in-page.json @@ -15,9 +15,17 @@ "slotProps": { "type": { "name": "shape", - "description": "{ emailField?: object, passwordField?: object, submitButton?: object }" + "description": "{ emailField?: object, forgotPasswordLink?: object, passwordField?: object, signUpLink?: object, submitButton?: object }" }, "default": "{}" + }, + "slots": { + "type": { + "name": "shape", + "description": "{ emailField?: elementType, forgotPasswordLink?: elementType, passwordField?: elementType, signUpLink?: elementType, submitButton?: elementType }" + }, + "default": "{}", + "additionalInfo": { "slotsApi": true } } }, "name": "SignInPage", diff --git a/docs/translations/api-docs/sign-in-page/sign-in-page.json b/docs/translations/api-docs/sign-in-page/sign-in-page.json index 69164999840..d27f08ae393 100644 --- a/docs/translations/api-docs/sign-in-page/sign-in-page.json +++ b/docs/translations/api-docs/sign-in-page/sign-in-page.json @@ -10,9 +10,8 @@ "callbackUrl": "The URL to redirect to after signing in." } }, - "slotProps": { - "description": "Props to pass to the constituent components in the credentials form" - } + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." } }, "classDescriptions": {} } diff --git a/packages/toolpad-core/src/SignInPage/SignInPage.tsx b/packages/toolpad-core/src/SignInPage/SignInPage.tsx index 405c353d2b9..09f3e1cdfa5 100644 --- a/packages/toolpad-core/src/SignInPage/SignInPage.tsx +++ b/packages/toolpad-core/src/SignInPage/SignInPage.tsx @@ -17,6 +17,7 @@ import GitHubIcon from '@mui/icons-material/GitHub'; import PasswordIcon from '@mui/icons-material/Password'; import FacebookIcon from '@mui/icons-material/Facebook'; import Stack from '@mui/material/Stack'; +import { LinkProps } from '@mui/material/Link'; import { BrandingContext, DocsContext, RouterContext } from '../shared/context'; const IconProviderMap = new Map([ @@ -98,7 +99,40 @@ export interface SignInPageProps { callbackUrl?: string, ) => void | Promise; /** - * Props to pass to the constituent components in the credentials form + * The components used for each slot inside. + * @default {} + * @example { forgotPasswordLink: Forgot password? } + * @example { signUpLink: Sign up } + */ + slots?: { + /** + * The custom email field component used in the credentials form. + * @default TextField + */ + emailField?: React.JSXElementConstructor; + /** + * The custom password field component used in the credentials form. + * @default TextField + */ + passwordField?: React.JSXElementConstructor; + /** + * The custom submit button component used in the credentials form. + * @default LoadingButton + */ + submitButton?: React.JSXElementConstructor; + /** + * The custom forgot password link component used in the credentials form. + * @default Link + */ + forgotPasswordLink?: React.JSXElementConstructor; + /** + * The custom sign up link component used in the credentials form. + * @default Link + */ + signUpLink?: React.JSXElementConstructor; + }; + /** + * The props used for each slot inside. * @default {} * @example { email: { autoFocus: false } } * @example { password: { variant: 'outlined' } } @@ -108,6 +142,8 @@ export interface SignInPageProps { emailField?: TextFieldProps; passwordField?: TextFieldProps; submitButton?: LoadingButtonProps; + forgotPasswordLink?: LinkProps; + signUpLink?: LinkProps; }; } @@ -122,7 +158,7 @@ export interface SignInPageProps { * - [SignInPage API](https://mui.com/toolpad/core/api/sign-in-page) */ function SignInPage(props: SignInPageProps) { - const { providers, signIn, slotProps } = props; + const { providers, signIn, slots, slotProps } = props; const branding = React.useContext(BrandingContext); const docs = React.useContext(DocsContext); const router = React.useContext(RouterContext); @@ -179,7 +215,7 @@ function SignInPage(props: SignInPageProps) { const oauthResponse = await signIn?.(provider, undefined, callbackUrl); setFormStatus((prev) => ({ ...prev, - loading: false, + loading: oauthResponse?.error || docs ? false : prev.loading, error: oauthResponse?.error, })); }} @@ -242,68 +278,96 @@ function SignInPage(props: SignInPageProps) { })); }} > - - + {slots?.emailField ? ( + + ) : ( + + )} + + {slots?.passwordField ? ( + + ) : ( + + )} + } label="Remember me" slotProps={{ typography: { color: 'textSecondary' } }} /> - - Sign in - + {slots?.submitButton ? ( + + ) : ( + + Sign in + + )} + + {slots?.forgotPasswordLink || slots?.signUpLink ? ( + + {slots?.forgotPasswordLink ? ( + + ) : null} + + {slots?.signUpLink ? : null} + + ) : null} ) : null} @@ -338,7 +402,7 @@ SignInPage.propTypes /* remove-proptypes */ = { */ signIn: PropTypes.func, /** - * Props to pass to the constituent components in the credentials form + * The props used for each slot inside. * @default {} * @example { email: { autoFocus: false } } * @example { password: { variant: 'outlined' } } @@ -346,9 +410,24 @@ SignInPage.propTypes /* remove-proptypes */ = { */ slotProps: PropTypes.shape({ emailField: PropTypes.object, + forgotPasswordLink: PropTypes.object, passwordField: PropTypes.object, + signUpLink: PropTypes.object, submitButton: PropTypes.object, }), + /** + * The components used for each slot inside. + * @default {} + * @example { forgotPasswordLink: Forgot password? } + * @example { signUpLink: Sign up } + */ + slots: PropTypes.shape({ + emailField: PropTypes.elementType, + forgotPasswordLink: PropTypes.elementType, + passwordField: PropTypes.elementType, + signUpLink: PropTypes.elementType, + submitButton: PropTypes.elementType, + }), } as any; export { SignInPage }; diff --git a/playground/nextjs-pages/src/pages/auth/signin.tsx b/playground/nextjs-pages/src/pages/auth/signin.tsx index b31e5a63ec2..699186efd5d 100644 --- a/playground/nextjs-pages/src/pages/auth/signin.tsx +++ b/playground/nextjs-pages/src/pages/auth/signin.tsx @@ -1,10 +1,19 @@ import * as React from 'react'; import type { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next'; +import Link from '@mui/material/Link'; import { SignInPage } from '@toolpad/core/SignInPage'; import { signIn } from 'next-auth/react'; import { useRouter } from 'next/router'; import { auth, providerMap } from '../../auth'; +function ForgotPasswordLink() { + return Forgot password?; +} + +function SignUpLink() { + return Sign up; +} + export default function SignIn({ providers, }: InferGetServerSidePropsType) { @@ -50,6 +59,7 @@ export default function SignIn({ }; } }} + slots={{ forgotPasswordLink: ForgotPasswordLink, signUpLink: SignUpLink }} /> ); } diff --git a/playground/nextjs/src/app/auth/signin/actions.ts b/playground/nextjs/src/app/auth/signin/actions.ts new file mode 100644 index 00000000000..5565a91cfa9 --- /dev/null +++ b/playground/nextjs/src/app/auth/signin/actions.ts @@ -0,0 +1,40 @@ +'use server'; +import { AuthError } from 'next-auth'; +import type { AuthProvider } from '@toolpad/core'; +import { signIn as signInAction } from '../../../auth'; + +async function signIn(provider: AuthProvider, formData: FormData, callbackUrl?: string) { + try { + return await signInAction(provider.id, { + ...(formData && { email: formData.get('email'), password: formData.get('password') }), + redirectTo: callbackUrl ?? '/', + }); + } catch (error) { + // The desired flow for successful sign in in all cases + // and unsuccessful sign in for OAuth providers will cause a `redirect`, + // and `redirect` is a throwing function, so we need to re-throw + // to allow the redirect to happen + // Source: https://github.com/vercel/next.js/issues/49298#issuecomment-1542055642 + // Detect a `NEXT_REDIRECT` error and re-throw it + if (error instanceof Error && error.message === 'NEXT_REDIRECT') { + throw error; + } + // Handle Auth.js errors + if (error instanceof AuthError) { + return { + error: + error.type === 'CredentialsSignin' + ? 'Invalid credentials.' + : 'An error with Auth.js occurred.', + type: error.type, + }; + } + // An error boundary must exist to handle unknown errors + return { + error: 'Something went wrong.', + type: 'UnknownError', + }; + } +} + +export default signIn; diff --git a/playground/nextjs/src/app/auth/signin/page.tsx b/playground/nextjs/src/app/auth/signin/page.tsx index 697e94ca73c..0347eda917b 100644 --- a/playground/nextjs/src/app/auth/signin/page.tsx +++ b/playground/nextjs/src/app/auth/signin/page.tsx @@ -1,46 +1,26 @@ +'use client'; import * as React from 'react'; -import type { AuthProvider } from '@toolpad/core'; +import Link from '@mui/material/Link'; import { SignInPage } from '@toolpad/core/SignInPage'; -import { AuthError } from 'next-auth'; -import { providerMap, signIn } from '../../../auth'; +import { providerMap } from '../../../auth'; +import signIn from './actions'; + +function ForgotPasswordLink() { + return Forgot password?; +} + +function SignUpLink() { + return Sign up; +} export default function SignIn() { return ( { - 'use server'; - try { - return await signIn(provider.id, { - ...(formData && { email: formData.get('email'), password: formData.get('password') }), - redirectTo: callbackUrl ?? '/', - }); - } catch (error) { - // The desired flow for successful sign in in all cases - // and unsuccessful sign in for OAuth providers will cause a `redirect`, - // and `redirect` is a throwing function, so we need to re-throw - // to allow the redirect to happen - // Source: https://github.com/vercel/next.js/issues/49298#issuecomment-1542055642 - // Detect a `NEXT_REDIRECT` error and re-throw it - if (error instanceof Error && error.message === 'NEXT_REDIRECT') { - throw error; - } - // Handle Auth.js errors - if (error instanceof AuthError) { - return { - error: - error.type === 'CredentialsSignin' - ? 'Invalid credentials.' - : 'An error with Auth.js occurred.', - type: error.type, - }; - } - // An error boundary must exist to handle unknown errors - return { - error: 'Something went wrong.', - type: 'UnknownError', - }; - } + signIn={signIn} + slots={{ + forgotPasswordLink: ForgotPasswordLink, + signUpLink: SignUpLink, }} /> );