WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 9335afe

Browse files
[cli] Support Magic Link and Passkeys in CLI (#4339)
1 parent dca1fc8 commit 9335afe

File tree

18 files changed

+461
-80
lines changed

18 files changed

+461
-80
lines changed

docs/data/toolpad/core/introduction/tutorial.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ title: Tutorial
1515
<codeblock storageKey="package-manager">
1616

1717
```bash npm
18-
npx create-toolpad-app@latest --example core-tutorial
18+
npx create-toolpad-app@latest --example tutorial
1919
```
2020

2121
```bash pnpm
22-
pnpm dlx create toolpad-app --example core-tutorial
22+
pnpm dlx create toolpad-app --example tutorial
2323
```
2424

2525
```bash yarn
26-
yarn create toolpad-app --example core-tutorial
26+
yarn create toolpad-app --example tutorial
2727
```
2828

2929
</codeblock>

examples/core/auth-nextjs-passkey/src/app/auth/signin/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,5 @@ const signIn = async (provider: AuthProvider, formData: FormData, callbackUrl?:
2727
};
2828

2929
export default function SignIn() {
30-
// TODO: Fix this
3130
return <SignInPage providers={providerMap} signIn={signIn} />;
3231
}

packages/create-toolpad-app/src/generateProject.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@ import auth from './templates/auth/auth';
2323
import envLocal from './templates/auth/envLocal';
2424
import middleware from './templates/auth/middleware';
2525
import routeHandler from './templates/auth/route';
26+
import prisma from './templates/auth/prisma';
27+
import env from './templates/auth/env';
28+
import schemaPrisma from './templates/auth/schemaPrisma';
2629

2730
// Auth files for app router
2831
import signInPage from './templates/auth/nextjs-app/signInPage';
32+
import signInAction from './templates/auth/nextjs-app/actions';
2933

3034
// Auth files for pages router
3135
import signInPagePagesRouter from './templates/auth/nextjs-pages/signIn';
@@ -59,7 +63,7 @@ export default function generateProject(
5963
case 'nextjs-pages': {
6064
const nextJsPagesRouterStarter = new Map([
6165
['pages/index.tsx', { content: indexPageContent }],
62-
['pages/orders/index.tsx', { content: ordersPage }],
66+
['pages/orders/index.tsx', { content: ordersPage(options) }],
6367
['pages/_document.tsx', { content: document }],
6468
['pages/_app.tsx', { content: app(options) }],
6569
]);
@@ -74,6 +78,16 @@ export default function generateProject(
7478
['app/api/auth/[...nextAuth]/route.ts', { content: routeHandler }],
7579
['pages/auth/signin.tsx', { content: signInPagePagesRouter(options) }],
7680
]);
81+
if (options.hasNodemailerProvider || options.hasPasskeyProvider) {
82+
// Prisma adapter support requires removal of middleware
83+
authFiles.delete('middleware.ts');
84+
const prismaFiles = new Map([
85+
['prisma.ts', { content: prisma }],
86+
['.env', { content: env }],
87+
['prisma/schema.prisma', { content: schemaPrisma(options) }],
88+
]);
89+
return new Map([...files, ...nextJsPagesRouterStarter, ...authFiles, ...prismaFiles]);
90+
}
7791
return new Map([...files, ...nextJsPagesRouterStarter, ...authFiles]);
7892
}
7993
return new Map([...files, ...nextJsPagesRouterStarter]);
@@ -84,7 +98,7 @@ export default function generateProject(
8498
['app/(dashboard)/layout.tsx', { content: dashboardLayout }],
8599
['app/layout.tsx', { content: rootLayout(options) }],
86100
['app/(dashboard)/page.tsx', { content: indexPageContent }],
87-
['app/(dashboard)/orders/page.tsx', { content: ordersPage }],
101+
['app/(dashboard)/orders/page.tsx', { content: ordersPage(options) }],
88102
]);
89103
if (options.auth) {
90104
const authFiles = new Map([
@@ -93,7 +107,18 @@ export default function generateProject(
93107
['middleware.ts', { content: middleware }],
94108
['app/api/auth/[...nextAuth]/route.ts', { content: routeHandler }],
95109
['app/auth/signin/page.tsx', { content: signInPage(options) }],
110+
['app/auth/signin/actions.ts', { content: signInAction(options) }],
96111
]);
112+
if (options.hasNodemailerProvider || options.hasPasskeyProvider) {
113+
// Prisma adapater support requires removal of middleware
114+
authFiles.delete('middleware.ts');
115+
const prismaFiles = new Map([
116+
['prisma.ts', { content: prisma }],
117+
['.env', { content: env }],
118+
['prisma/schema.prisma', { content: schemaPrisma(options) }],
119+
]);
120+
return new Map([...files, ...nextJsAppRouterStarter, ...authFiles, ...prismaFiles]);
121+
}
97122

98123
return new Map([...files, ...nextJsAppRouterStarter, ...authFiles]);
99124
}

packages/create-toolpad-app/src/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ const run = async () => {
281281

282282
const absolutePath = bashResolvePath(projectPath);
283283

284+
let hasNodemailerProvider = false;
285+
let hasPasskeyProvider = false;
286+
284287
// If the user has provided an example, download and extract it
285288
if (example) {
286289
await downloadAndExtractExample(absolutePath, example);
@@ -311,6 +314,9 @@ const run = async () => {
311314
choices: [
312315
{ name: 'Google', value: 'google' },
313316
{ name: 'GitHub', value: 'github' },
317+
{ name: 'Passkey', value: 'passkey' },
318+
{ name: 'Magic Link', value: 'nodemailer' },
319+
{ name: 'Credentials', value: 'credentials' },
314320
{ name: 'GitLab', value: 'gitlab' },
315321
{ name: 'Twitter', value: 'twitter' },
316322
{ name: 'Facebook', value: 'facebook' },
@@ -331,6 +337,8 @@ const run = async () => {
331337
{ name: 'FusionAuth', value: 'fusionauth' },
332338
],
333339
});
340+
hasNodemailerProvider = authProviderOptions?.includes('nodemailer');
341+
hasPasskeyProvider = authProviderOptions?.includes('passkey');
334342
}
335343
const options = {
336344
name: path.basename(absolutePath),
@@ -340,6 +348,9 @@ const run = async () => {
340348
auth: authFlag,
341349
install: installFlag,
342350
authProviders: authProviderOptions,
351+
hasCredentialsProvider: authProviderOptions?.includes('credentials'),
352+
hasNodemailerProvider,
353+
hasPasskeyProvider,
343354
};
344355
await scaffoldCoreProject(options);
345356
}
@@ -355,11 +366,17 @@ const run = async () => {
355366

356367
const installInstruction = example || !installFlag ? ` ${packageManager} install\n` : '';
357368

369+
const databaseInstruction =
370+
hasNodemailerProvider || hasPasskeyProvider
371+
? ` npx prisma migrate dev --schema=prisma/schema.prisma\n`
372+
: '';
373+
358374
const message = `Run the following to get started: \n\n${chalk.magentaBright(
359-
`${changeDirectoryInstruction}${installInstruction} ${packageManager}${
375+
`${changeDirectoryInstruction}${databaseInstruction}${installInstruction} ${packageManager}${
360376
packageManager === 'yarn' ? '' : ' run'
361377
} dev`,
362378
)}`;
379+
363380
// eslint-disable-next-line no-console
364381
console.log(message);
365382
// eslint-disable-next-line no-console

packages/create-toolpad-app/src/templates/auth/auth.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,36 +20,56 @@ const CredentialsProviderTemplate = `Credentials({
2020
},
2121
}),`;
2222

23+
const NodemailerTemplate = `Nodemailer({
24+
server: {
25+
host: process.env.EMAIL_SERVER_HOST,
26+
port: process.env.EMAIL_SERVER_PORT,
27+
auth: {
28+
user: process.env.EMAIL_SERVER_USER,
29+
pass: process.env.EMAIL_SERVER_PASSWORD,
30+
},
31+
secure: true,
32+
},
33+
from: process.env.EMAIL_FROM,
34+
}),`;
35+
36+
const PasskeyTemplate = 'Passkey,';
37+
2338
const oAuthProviderTemplate = (provider: SupportedAuthProvider) => `
2439
${kebabToPascal(provider)}({
2540
clientId: process.env.${kebabToConstant(provider)}_CLIENT_ID,
2641
clientSecret: process.env.${kebabToConstant(provider)}_CLIENT_SECRET,${requiresIssuer(provider) ? `\n\t\tissuer: process.env.${kebabToConstant(provider)}_ISSUER,` : ''}${requiresTenantId(provider) ? `\n\t\ttenantId: process.env.${kebabToConstant(provider)}_TENANT_ID,` : ''}
2742
}),`;
2843
const checkEnvironmentVariables = (providers: SupportedAuthProvider[] | undefined) => `${providers
2944
?.filter((p) => p !== 'credentials')
30-
.map(
31-
(provider) =>
32-
`if(!process.env.${kebabToConstant(provider)}_CLIENT_ID) {
45+
.map((provider) => {
46+
if (provider === 'nodemailer') {
47+
return `if(!process.env.DATABASE_URL || !process.env.EMAIL_SERVER_HOST) { \nconsole.warn('The Nodemailer provider requires configuring a database and an email server.')\n}`;
48+
}
49+
if (provider === 'passkey') {
50+
return `if(!process.env.DATABASE_URL) { \nconsole.warn('The passkey provider requires configuring a database.')\n}`;
51+
}
52+
return `if(!process.env.${kebabToConstant(provider)}_CLIENT_ID) {
3353
console.warn('Missing environment variable "${kebabToConstant(provider)}_CLIENT_ID"');
3454
}
3555
if(!process.env.${kebabToConstant(provider)}_CLIENT_SECRET) {
3656
console.warn('Missing environment variable "${kebabToConstant(provider)}_CLIENT_SECRET"');
3757
}${
38-
requiresTenantId(provider)
39-
? `
58+
requiresTenantId(provider)
59+
? `
4060
if(!process.env.${kebabToConstant(provider)}_TENANT_ID) {
4161
console.warn('Missing environment variable "${kebabToConstant(provider)}_TENANT_ID"');
4262
}`
43-
: ''
44-
}${
45-
requiresIssuer(provider)
46-
? `
63+
: ''
64+
}${
65+
requiresIssuer(provider)
66+
? `
4767
if(!process.env.${kebabToConstant(provider)}_ISSUER) {
4868
console.warn('Missing environment variable "${kebabToConstant(provider)}_ISSUER"');
4969
}`
50-
: ''
51-
}`,
52-
)
70+
: ''
71+
}`;
72+
})
5373
.join('\n')}
5474
`;
5575

@@ -63,12 +83,19 @@ const auth: Template = (options) => {
6383
)
6484
.join('\n')}
6585
import type { Provider } from 'next-auth/providers';
86+
${options.hasNodemailerProvider || options.hasPasskeyProvider ? `\nimport { PrismaAdapter } from '@auth/prisma-adapter';\nimport { prisma } from './prisma';` : ''}
6687
6788
const providers: Provider[] = [${providers
6889
?.map((provider) => {
6990
if (provider === 'credentials') {
7091
return CredentialsProviderTemplate;
7192
}
93+
if (provider === 'nodemailer') {
94+
return NodemailerTemplate;
95+
}
96+
if (provider === 'passkey') {
97+
return PasskeyTemplate;
98+
}
7299
return oAuthProviderTemplate(provider);
73100
})
74101
.join('\n')}
@@ -86,6 +113,15 @@ export const providerMap = providers.map((provider) => {
86113
87114
export const { handlers, auth, signIn, signOut } = NextAuth({
88115
providers,
116+
${options.hasNodemailerProvider || options.hasPasskeyProvider ? `\nadapter: PrismaAdapter(prisma),` : ''}
117+
${options.hasNodemailerProvider || (options.router === 'nextjs-app' && options.hasPasskeyProvider && providers && providers.length > 1) ? `\nsession: { strategy: 'jwt' },` : ''}
118+
${
119+
options.hasPasskeyProvider
120+
? `\nexperimental: {
121+
enableWebAuthn: true,
122+
},`
123+
: ''
124+
}
89125
secret: process.env.AUTH_SECRET,
90126
pages: {
91127
signIn: '/auth/signin',
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const env = `
2+
DATABASE_URL =
3+
`;
4+
5+
export default env;

packages/create-toolpad-app/src/templates/auth/envLocal.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import { Template } from '../../types';
44

55
const env: Template = (options) => {
66
const { authProviders: providers } = options;
7-
const nonCredentialProviders = providers?.filter((provider) => provider !== 'credentials');
7+
const nonCredentialProviders = providers?.filter(
8+
(provider) => provider !== 'credentials' && provider !== 'nodemailer' && provider !== 'passkey',
9+
);
810
return `
911
# Generate a secret with \`npx auth secret\`
1012
# and replace the value below with it
1113
AUTH_SECRET=secret
1214
1315
# Add secrets for your auth providers to the .env.local file
14-
1516
${nonCredentialProviders
1617
?.map(
1718
(provider) => `
@@ -20,7 +21,12 @@ ${kebabToConstant(provider)}_CLIENT_SECRET=
2021
${requiresIssuer(provider) ? `${kebabToConstant(provider)}_ISSUER=\n` : ''}${requiresTenantId(provider) ? `${kebabToConstant(provider)}_TENANT_ID=\n` : ''}`,
2122
)
2223
.join('\n')}
23-
`;
24+
25+
${
26+
options.hasNodemailerProvider
27+
? `EMAIL_SERVER_HOST=\nEMAIL_SERVER_PORT=\nEMAIL_SERVER_USER=\nEMAIL_SERVER_PASSWORD=\nEMAIL_FROM=`
28+
: ''
29+
}`;
2430
};
2531

2632
export default env;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Template } from '../../../types';
2+
3+
const actionsTemplate: Template = (options) => {
4+
const { hasCredentialsProvider, hasNodemailerProvider } = options;
5+
6+
return `'use server';
7+
import { AuthError } from 'next-auth';
8+
import type { AuthProvider } from '@toolpad/core';
9+
import { signIn } from '../../../auth';
10+
11+
export default async function serverSignIn(provider: AuthProvider, formData: FormData, callbackUrl?: string) {
12+
try {
13+
return await signIn(provider.id, {
14+
...(formData && { email: formData.get('email'), password: formData.get('password') }),
15+
redirectTo: callbackUrl ?? '/',
16+
});
17+
} catch (error) {
18+
if (error instanceof Error && error.message === 'NEXT_REDIRECT') {
19+
${
20+
hasNodemailerProvider
21+
? `if (provider.id === 'nodemailer' && (error as any).digest?.includes('verify-request')) {
22+
return {
23+
success: 'Check your email for a verification link.',
24+
};
25+
}`
26+
: ''
27+
}
28+
throw error;
29+
}
30+
if (error instanceof AuthError) {
31+
return {
32+
error: ${
33+
hasCredentialsProvider
34+
? `error.type === 'CredentialsSignin' ? 'Invalid credentials.' : 'An error with Auth.js occurred.'`
35+
: `'An error with Auth.js occurred.'`
36+
},
37+
type: error.type,
38+
};
39+
}
40+
return {
41+
error: 'Something went wrong.',
42+
type: 'UnknownError',
43+
};
44+
}
45+
}`;
46+
};
47+
48+
export default actionsTemplate;

0 commit comments

Comments
 (0)