Add OTP sign-in option + Account Linking (#276)
* feat(accounts): allow linking email password * feat(auth): add OTP sign-in * refactor(accounts): remove 'sonner' dependency and update toast imports * feat(supabase): enable analytics and configure database seeding * feat(auth): update email templates and add OTP template * feat(auth): add last sign in method hints * feat(config): add devIndicators position to bottom-right * feat(auth): implement comprehensive last authentication method tracking tests
This commit is contained in:
committed by
GitHub
parent
856e9612c4
commit
9033155fcd
@@ -9,10 +9,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"@hookform/resolvers": "^5.1.0",
|
||||
"@tanstack/react-query": "5.80.6",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"ai": "4.3.16",
|
||||
"lucide-react": "^0.513.0",
|
||||
"lucide-react": "^0.514.0",
|
||||
"next": "15.3.3",
|
||||
"nodemailer": "^7.0.3",
|
||||
"react": "19.1.0",
|
||||
@@ -26,17 +26,17 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"tailwindcss": "4.1.8",
|
||||
"tailwindcss": "4.1.10",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.8.3",
|
||||
"zod": "^3.25.56"
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"browserslist": [
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^22.15.30",
|
||||
"@playwright/test": "^1.53.0",
|
||||
"@types/node": "^24.0.1",
|
||||
"dotenv": "16.5.0",
|
||||
"node-html-parser": "^7.0.1",
|
||||
"totp-generator": "^1.0.0"
|
||||
|
||||
@@ -96,3 +96,147 @@ test.describe('Protected routes', () => {
|
||||
expect(page.url()).toContain('/auth/sign-in?next=/home/settings');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Last auth method tracking', () => {
|
||||
let testEmail: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
testEmail = auth.createRandomEmail();
|
||||
|
||||
// First, sign up with password
|
||||
await auth.goToSignUp();
|
||||
|
||||
await auth.signUp({
|
||||
email: testEmail,
|
||||
password: 'password123',
|
||||
repeatPassword: 'password123',
|
||||
});
|
||||
|
||||
await auth.visitConfirmEmailLink(testEmail);
|
||||
await page.waitForURL('**/home');
|
||||
|
||||
// Sign out
|
||||
await auth.signOut();
|
||||
await page.waitForURL('/');
|
||||
});
|
||||
|
||||
test('should show last used method hint on sign-in page after password sign-in', async ({
|
||||
page,
|
||||
}) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
// Go to sign-in page and check for last method hint
|
||||
await auth.goToSignIn();
|
||||
|
||||
// Check if the last used method hint is visible
|
||||
const lastMethodHint = page.locator('[data-test="last-auth-method-hint"]');
|
||||
await expect(lastMethodHint).toBeVisible();
|
||||
|
||||
// Verify it shows the correct method (password)
|
||||
const passwordMethodText = page.locator('text=email and password');
|
||||
await expect(passwordMethodText).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show existing account hint on sign-up page after previous sign-in', async ({
|
||||
page,
|
||||
}) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
// Go to sign-up page (user already signed in with password in previous test)
|
||||
await auth.goToSignUp();
|
||||
|
||||
// Check if the existing account hint is visible
|
||||
const existingAccountHint = page.locator(
|
||||
'[data-test="existing-account-hint"]',
|
||||
);
|
||||
|
||||
await expect(existingAccountHint).toBeVisible();
|
||||
});
|
||||
|
||||
test('should track method after successful sign-in', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
// Clear cookies to simulate a fresh session
|
||||
await page.context().clearCookies();
|
||||
|
||||
// Sign in with the test email
|
||||
await auth.goToSignIn();
|
||||
|
||||
await auth.signIn({
|
||||
email: testEmail,
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
await page.waitForURL('**/home');
|
||||
|
||||
// Sign out and check the method is still tracked
|
||||
await auth.signOut();
|
||||
await page.waitForURL('/');
|
||||
|
||||
// Go to sign-in page and check for last method hint
|
||||
await auth.goToSignIn();
|
||||
|
||||
// The hint should still be visible after signing in again
|
||||
const lastMethodHint = page.locator('[data-test="last-auth-method-hint"]');
|
||||
|
||||
await expect(lastMethodHint).toBeVisible();
|
||||
});
|
||||
|
||||
test('should clear localStorage after 30 days simulation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
// Go to sign-in page first
|
||||
await auth.goToSignIn();
|
||||
|
||||
// Simulate old timestamp (31 days ago) by directly modifying localStorage
|
||||
const thirtyOneDaysAgo = Date.now() - 31 * 24 * 60 * 60 * 1000;
|
||||
|
||||
await page.evaluate((timestamp) => {
|
||||
const oldAuthMethod = {
|
||||
method: 'password',
|
||||
email: 'old@example.com',
|
||||
timestamp: timestamp,
|
||||
};
|
||||
localStorage.setItem('auth_last_method', JSON.stringify(oldAuthMethod));
|
||||
}, thirtyOneDaysAgo);
|
||||
|
||||
// Reload the page to trigger the expiry check
|
||||
await page.reload();
|
||||
|
||||
// The hint should not be visible for expired data
|
||||
const lastMethodHint = page.locator('[data-test="last-auth-method-hint"]');
|
||||
await expect(lastMethodHint).not.toBeVisible();
|
||||
|
||||
// Verify localStorage was cleared
|
||||
const storedMethod = await page.evaluate(() => {
|
||||
return localStorage.getItem('auth_last_method');
|
||||
});
|
||||
|
||||
expect(storedMethod).toBeNull();
|
||||
});
|
||||
|
||||
test('should handle localStorage errors gracefully', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await auth.goToSignIn();
|
||||
|
||||
// Simulate corrupted localStorage data
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('auth_last_method', 'invalid-json-data');
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
|
||||
// Should not crash and not show the hint
|
||||
const lastMethodHint = page.locator('[data-test="last-auth-method-hint"]');
|
||||
await expect(lastMethodHint).not.toBeVisible();
|
||||
|
||||
// Page should still be functional
|
||||
await expect(page.locator('input[name="email"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,11 @@ import { requireUserInServerComponent } from '~/lib/server/require-user-in-serve
|
||||
const features = {
|
||||
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
|
||||
enablePasswordUpdate: authConfig.providers.password,
|
||||
enableAccountLinking: authConfig.enableIdentityLinking,
|
||||
};
|
||||
|
||||
const providers = authConfig.providers.oAuth;
|
||||
|
||||
const callbackPath = pathsConfig.auth.callback;
|
||||
const accountHomePath = pathsConfig.app.accountHome;
|
||||
|
||||
@@ -41,6 +44,7 @@ function PersonalAccountSettingsPage() {
|
||||
userId={user.id}
|
||||
features={features}
|
||||
paths={paths}
|
||||
providers={providers}
|
||||
/>
|
||||
</div>
|
||||
</PageBody>
|
||||
|
||||
@@ -15,6 +15,12 @@ const AuthConfigSchema = z.object({
|
||||
description: 'Whether to display the terms checkbox during sign-up.',
|
||||
})
|
||||
.optional(),
|
||||
enableIdentityLinking: z
|
||||
.boolean({
|
||||
description: 'Allow linking and unlinking of auth identities.',
|
||||
})
|
||||
.optional()
|
||||
.default(false),
|
||||
providers: z.object({
|
||||
password: z.boolean({
|
||||
description: 'Enable password authentication.',
|
||||
@@ -22,6 +28,9 @@ const AuthConfigSchema = z.object({
|
||||
magicLink: z.boolean({
|
||||
description: 'Enable magic link authentication.',
|
||||
}),
|
||||
otp: z.boolean({
|
||||
description: 'Enable one-time password authentication.',
|
||||
}),
|
||||
oAuth: providers.array(),
|
||||
}),
|
||||
});
|
||||
@@ -35,11 +44,17 @@ const authConfig = AuthConfigSchema.parse({
|
||||
displayTermsCheckbox:
|
||||
process.env.NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX === 'true',
|
||||
|
||||
// whether to enable identity linking:
|
||||
// This needs to be enabled in the Supabase Console as well for it to work.
|
||||
enableIdentityLinking:
|
||||
process.env.NEXT_PUBLIC_AUTH_IDENTITY_LINKING === 'true',
|
||||
|
||||
// NB: Enable the providers below in the Supabase Console
|
||||
// in your production project
|
||||
providers: {
|
||||
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
|
||||
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
|
||||
otp: process.env.NEXT_PUBLIC_AUTH_OTP === 'true',
|
||||
oAuth: ['google'],
|
||||
},
|
||||
} satisfies z.infer<typeof AuthConfigSchema>);
|
||||
|
||||
@@ -46,6 +46,9 @@ const config = {
|
||||
resolveExtensions: ['.ts', '.tsx', '.js', '.jsx'],
|
||||
resolveAlias: getModulesAliases(),
|
||||
},
|
||||
devIndicators: {
|
||||
position: 'bottom-right',
|
||||
},
|
||||
experimental: {
|
||||
mdxRs: true,
|
||||
reactCompiler: ENABLE_REACT_COMPILER,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edge-csrf/nextjs": "2.5.3-cloudflare-rc1",
|
||||
"@hookform/resolvers": "^5.1.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@kit/accounts": "workspace:*",
|
||||
"@kit/admin": "workspace:*",
|
||||
"@kit/analytics": "workspace:*",
|
||||
@@ -57,21 +57,20 @@
|
||||
"@nosecone/next": "1.0.0-beta.8",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "2.50.0",
|
||||
"@tanstack/react-query": "5.80.6",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.513.0",
|
||||
"lucide-react": "^0.514.0",
|
||||
"next": "15.3.3",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.57.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"react-i18next": "^15.5.3",
|
||||
"recharts": "2.15.3",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"zod": "^3.25.56"
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^3.25.63"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
@@ -79,15 +78,15 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@next/bundle-analyzer": "15.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/node": "^24.0.1",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"cssnano": "^7.0.7",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"supabase": "^2.24.3",
|
||||
"tailwindcss": "4.1.8",
|
||||
"tailwindcss": "4.1.10",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"emailNotMatching": "Emails do not match. Make sure you're using the correct email",
|
||||
"passwordNotChanged": "Your password has not changed",
|
||||
"emailsNotMatching": "Emails do not match. Make sure you're using the correct email",
|
||||
"cannotUpdatePassword": "You cannot update your password because your account is not linked to any.",
|
||||
"cannotUpdatePassword": "You cannot update your password because your account is not linked to an email.",
|
||||
"setupMfaButtonLabel": "Setup a new Factor",
|
||||
"multiFactorSetupErrorHeading": "Setup Failed",
|
||||
"multiFactorSetupErrorDescription": "Sorry, there was an error while setting up your factor. Please try again.",
|
||||
@@ -111,5 +111,29 @@
|
||||
"languageDescription": "Choose your preferred language",
|
||||
"noTeamsYet": "You don't have any teams yet.",
|
||||
"createTeam": "Create a team to get started.",
|
||||
"createTeamButtonLabel": "Create a Team"
|
||||
"createTeamButtonLabel": "Create a Team",
|
||||
"linkedAccounts": "Linked Accounts",
|
||||
"linkedAccountsDescription": "Connect other authentication providers",
|
||||
"unlinkAccountButton": "Unlink {{provider}}",
|
||||
"unlinkAccountSuccess": "Account unlinked",
|
||||
"unlinkAccountError": "Unlinking failed",
|
||||
"linkAccountSuccess": "Account linked",
|
||||
"linkAccountError": "Linking failed",
|
||||
"linkEmailPasswordButton": "Add Email & Password",
|
||||
"linkEmailPasswordSuccess": "Email and password linked",
|
||||
"linkEmailPasswordError": "Failed to link email and password",
|
||||
"linkingAccount": "Linking account...",
|
||||
"accountLinked": "Account linked",
|
||||
"unlinkAccount": "Unlink Account",
|
||||
"failedToLinkAccount": "Failed to link account",
|
||||
"availableAccounts": "Available Accounts",
|
||||
"availableAccountsDescription": "Connect other authentication providers to your account",
|
||||
"alreadyLinkedAccountsDescription": "You have already linked these accounts",
|
||||
"confirmUnlinkAccount": "You are unlinking this provider.",
|
||||
"unlinkAccountConfirmation": "Are you sure you want to unlink this provider from your account? This action cannot be undone.",
|
||||
"unlinkingAccount": "Unlinking account...",
|
||||
"accountUnlinked": "Account successfully unlinked",
|
||||
"linkEmailPassword": "Email & Password",
|
||||
"linkEmailPasswordDescription": "Add an email and password to your account for additional sign-in options",
|
||||
"noAccountsAvailable": "No additional accounts available to link"
|
||||
}
|
||||
|
||||
@@ -70,18 +70,27 @@
|
||||
"privacyPolicy": "Privacy Policy",
|
||||
"orContinueWith": "Or continue with",
|
||||
"redirecting": "You're in! Please wait...",
|
||||
"lastUsedMethodPrefix": "You last signed in with",
|
||||
"methodPassword": "email and password",
|
||||
"methodOtp": "OTP code",
|
||||
"methodMagicLink": "email link",
|
||||
"methodOauth": "social sign-in",
|
||||
"methodOauthWithProvider": "<provider>{{provider}}</provider>",
|
||||
"existingAccountHint": "You previously signed in with <method>{{method}}</method>. <signInLink>Already have an account?</signInLink>",
|
||||
"errors": {
|
||||
"Invalid login credentials": "The credentials entered are invalid",
|
||||
"User already registered": "This credential is already in use. Please try with another one.",
|
||||
"Email not confirmed": "Please confirm your email address before signing in",
|
||||
"default": "We have encountered an error. Please ensure you have a working internet connection and try again",
|
||||
"generic": "Sorry, we weren't able to authenticate you. Please try again.",
|
||||
"link": "Sorry, we encountered an error while sending your link. Please try again.",
|
||||
"linkTitle": "Sign in failed",
|
||||
"linkDescription": "Sorry, we weren't able to sign you in. Please try again.",
|
||||
"codeVerifierMismatch": "It looks like you're trying to sign in using a different browser than the one you used to request the sign in link. Please try again using the same browser.",
|
||||
"minPasswordLength": "Password must be at least 8 characters long",
|
||||
"passwordsDoNotMatch": "The passwords do not match",
|
||||
"minPasswordNumbers": "Password must contain at least one number",
|
||||
"minPasswordSpecialChars": "Password must contain at least one special character",
|
||||
"Signups not allowed for otp": "OTP is disabled. Please enable it in your account settings.",
|
||||
"uppercasePassword": "Password must contain at least one uppercase letter",
|
||||
"insufficient_aal": "Please sign-in with your current multi-factor authentication to perform this action",
|
||||
"otp_expired": "The email link has expired. Please try again.",
|
||||
|
||||
@@ -100,9 +100,14 @@ subject = "Sign in to Makerkit"
|
||||
content_path = "./supabase/templates/magic-link.html"
|
||||
|
||||
[analytics]
|
||||
enabled = false
|
||||
enabled = true
|
||||
port = 54327
|
||||
backend = "postgres"
|
||||
|
||||
[db.migrations]
|
||||
schema_paths = [
|
||||
"./schemas/*.sql",
|
||||
]
|
||||
]
|
||||
|
||||
[db.seed]
|
||||
sql_paths = ['seed.sql', './seeds/*.sql']
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
8
apps/web/supabase/templates/otp.html
Normal file
8
apps/web/supabase/templates/otp.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user