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:
Giancarlo Buomprisco
2025-06-13 16:47:35 +07:00
committed by GitHub
parent 856e9612c4
commit 9033155fcd
87 changed files with 2580 additions and 1172 deletions

View File

@@ -9,10 +9,10 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^1.3.22", "@ai-sdk/openai": "^1.3.22",
"@hookform/resolvers": "^5.1.0", "@hookform/resolvers": "^5.1.1",
"@tanstack/react-query": "5.80.6", "@tanstack/react-query": "5.80.7",
"ai": "4.3.16", "ai": "4.3.16",
"lucide-react": "^0.513.0", "lucide-react": "^0.514.0",
"next": "15.3.3", "next": "15.3.3",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"react": "19.1.0", "react": "19.1.0",
@@ -26,17 +26,17 @@
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.15.30", "@types/node": "^24.0.1",
"@types/nodemailer": "6.4.17", "@types/nodemailer": "6.4.17",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"babel-plugin-react-compiler": "19.1.0-rc.2", "babel-plugin-react-compiler": "19.1.0-rc.2",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"tailwindcss": "4.1.8", "tailwindcss": "4.1.10",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"prettier": "@kit/prettier-config", "prettier": "@kit/prettier-config",
"browserslist": [ "browserslist": [

View File

@@ -12,8 +12,8 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.52.0", "@playwright/test": "^1.53.0",
"@types/node": "^22.15.30", "@types/node": "^24.0.1",
"dotenv": "16.5.0", "dotenv": "16.5.0",
"node-html-parser": "^7.0.1", "node-html-parser": "^7.0.1",
"totp-generator": "^1.0.0" "totp-generator": "^1.0.0"

View File

@@ -96,3 +96,147 @@ test.describe('Protected routes', () => {
expect(page.url()).toContain('/auth/sign-in?next=/home/settings'); 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();
});
});

View File

@@ -13,8 +13,11 @@ import { requireUserInServerComponent } from '~/lib/server/require-user-in-serve
const features = { const features = {
enableAccountDeletion: featureFlagsConfig.enableAccountDeletion, enableAccountDeletion: featureFlagsConfig.enableAccountDeletion,
enablePasswordUpdate: authConfig.providers.password, enablePasswordUpdate: authConfig.providers.password,
enableAccountLinking: authConfig.enableIdentityLinking,
}; };
const providers = authConfig.providers.oAuth;
const callbackPath = pathsConfig.auth.callback; const callbackPath = pathsConfig.auth.callback;
const accountHomePath = pathsConfig.app.accountHome; const accountHomePath = pathsConfig.app.accountHome;
@@ -41,6 +44,7 @@ function PersonalAccountSettingsPage() {
userId={user.id} userId={user.id}
features={features} features={features}
paths={paths} paths={paths}
providers={providers}
/> />
</div> </div>
</PageBody> </PageBody>

View File

@@ -15,6 +15,12 @@ const AuthConfigSchema = z.object({
description: 'Whether to display the terms checkbox during sign-up.', description: 'Whether to display the terms checkbox during sign-up.',
}) })
.optional(), .optional(),
enableIdentityLinking: z
.boolean({
description: 'Allow linking and unlinking of auth identities.',
})
.optional()
.default(false),
providers: z.object({ providers: z.object({
password: z.boolean({ password: z.boolean({
description: 'Enable password authentication.', description: 'Enable password authentication.',
@@ -22,6 +28,9 @@ const AuthConfigSchema = z.object({
magicLink: z.boolean({ magicLink: z.boolean({
description: 'Enable magic link authentication.', description: 'Enable magic link authentication.',
}), }),
otp: z.boolean({
description: 'Enable one-time password authentication.',
}),
oAuth: providers.array(), oAuth: providers.array(),
}), }),
}); });
@@ -35,11 +44,17 @@ const authConfig = AuthConfigSchema.parse({
displayTermsCheckbox: displayTermsCheckbox:
process.env.NEXT_PUBLIC_DISPLAY_TERMS_AND_CONDITIONS_CHECKBOX === 'true', 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 // NB: Enable the providers below in the Supabase Console
// in your production project // in your production project
providers: { providers: {
password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true', password: process.env.NEXT_PUBLIC_AUTH_PASSWORD === 'true',
magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true', magicLink: process.env.NEXT_PUBLIC_AUTH_MAGIC_LINK === 'true',
otp: process.env.NEXT_PUBLIC_AUTH_OTP === 'true',
oAuth: ['google'], oAuth: ['google'],
}, },
} satisfies z.infer<typeof AuthConfigSchema>); } satisfies z.infer<typeof AuthConfigSchema>);

View File

@@ -46,6 +46,9 @@ const config = {
resolveExtensions: ['.ts', '.tsx', '.js', '.jsx'], resolveExtensions: ['.ts', '.tsx', '.js', '.jsx'],
resolveAlias: getModulesAliases(), resolveAlias: getModulesAliases(),
}, },
devIndicators: {
position: 'bottom-right',
},
experimental: { experimental: {
mdxRs: true, mdxRs: true,
reactCompiler: ENABLE_REACT_COMPILER, reactCompiler: ENABLE_REACT_COMPILER,

View File

@@ -32,7 +32,7 @@
}, },
"dependencies": { "dependencies": {
"@edge-csrf/nextjs": "2.5.3-cloudflare-rc1", "@edge-csrf/nextjs": "2.5.3-cloudflare-rc1",
"@hookform/resolvers": "^5.1.0", "@hookform/resolvers": "^5.1.1",
"@kit/accounts": "workspace:*", "@kit/accounts": "workspace:*",
"@kit/admin": "workspace:*", "@kit/admin": "workspace:*",
"@kit/analytics": "workspace:*", "@kit/analytics": "workspace:*",
@@ -57,21 +57,20 @@
"@nosecone/next": "1.0.0-beta.8", "@nosecone/next": "1.0.0-beta.8",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.6", "@tanstack/react-query": "5.80.7",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.513.0", "lucide-react": "^0.514.0",
"next": "15.3.3", "next": "15.3.3",
"next-sitemap": "^4.2.3", "next-sitemap": "^4.2.3",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"react-i18next": "^15.5.2", "react-i18next": "^15.5.3",
"recharts": "2.15.3", "recharts": "2.15.3",
"sonner": "^2.0.5", "tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.3.0", "zod": "^3.25.63"
"zod": "^3.25.56"
}, },
"devDependencies": { "devDependencies": {
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
@@ -79,15 +78,15 @@
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@next/bundle-analyzer": "15.3.3", "@next/bundle-analyzer": "15.3.3",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.15.30", "@types/node": "^24.0.1",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"babel-plugin-react-compiler": "19.1.0-rc.2", "babel-plugin-react-compiler": "19.1.0-rc.2",
"cssnano": "^7.0.7", "cssnano": "^7.0.7",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"supabase": "^2.24.3", "supabase": "^2.24.3",
"tailwindcss": "4.1.8", "tailwindcss": "4.1.10",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },

View File

@@ -42,7 +42,7 @@
"emailNotMatching": "Emails do not match. Make sure you're using the correct email", "emailNotMatching": "Emails do not match. Make sure you're using the correct email",
"passwordNotChanged": "Your password has not changed", "passwordNotChanged": "Your password has not changed",
"emailsNotMatching": "Emails do not match. Make sure you're using the correct email", "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", "setupMfaButtonLabel": "Setup a new Factor",
"multiFactorSetupErrorHeading": "Setup Failed", "multiFactorSetupErrorHeading": "Setup Failed",
"multiFactorSetupErrorDescription": "Sorry, there was an error while setting up your factor. Please try again.", "multiFactorSetupErrorDescription": "Sorry, there was an error while setting up your factor. Please try again.",
@@ -111,5 +111,29 @@
"languageDescription": "Choose your preferred language", "languageDescription": "Choose your preferred language",
"noTeamsYet": "You don't have any teams yet.", "noTeamsYet": "You don't have any teams yet.",
"createTeam": "Create a team to get started.", "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"
} }

View File

@@ -70,18 +70,27 @@
"privacyPolicy": "Privacy Policy", "privacyPolicy": "Privacy Policy",
"orContinueWith": "Or continue with", "orContinueWith": "Or continue with",
"redirecting": "You're in! Please wait...", "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": { "errors": {
"Invalid login credentials": "The credentials entered are invalid", "Invalid login credentials": "The credentials entered are invalid",
"User already registered": "This credential is already in use. Please try with another one.", "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", "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", "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.", "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.", "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", "minPasswordLength": "Password must be at least 8 characters long",
"passwordsDoNotMatch": "The passwords do not match", "passwordsDoNotMatch": "The passwords do not match",
"minPasswordNumbers": "Password must contain at least one number", "minPasswordNumbers": "Password must contain at least one number",
"minPasswordSpecialChars": "Password must contain at least one special character", "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", "uppercasePassword": "Password must contain at least one uppercase letter",
"insufficient_aal": "Please sign-in with your current multi-factor authentication to perform this action", "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.", "otp_expired": "The email link has expired. Please try again.",

View File

@@ -100,9 +100,14 @@ subject = "Sign in to Makerkit"
content_path = "./supabase/templates/magic-link.html" content_path = "./supabase/templates/magic-link.html"
[analytics] [analytics]
enabled = false enabled = true
port = 54327
backend = "postgres"
[db.migrations] [db.migrations]
schema_paths = [ schema_paths = [
"./schemas/*.sql", "./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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-supabase-saas-kit-turbo", "name": "next-supabase-saas-kit-turbo",
"version": "2.10.0", "version": "2.11.0",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"engines": { "engines": {

View File

@@ -16,7 +16,7 @@
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@types/node": "^22.15.30" "@types/node": "^24.0.1"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -21,7 +21,7 @@
"@kit/supabase": "workspace:*", "@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -16,7 +16,7 @@
"./marketing": "./src/components/marketing.tsx" "./marketing": "./src/components/marketing.tsx"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^5.1.0", "@hookform/resolvers": "^5.1.1",
"@kit/billing": "workspace:*", "@kit/billing": "workspace:*",
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/lemon-squeezy": "workspace:*", "@kit/lemon-squeezy": "workspace:*",
@@ -27,14 +27,14 @@
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.513.0", "lucide-react": "^0.514.0",
"next": "15.3.3", "next": "15.3.3",
"react": "19.1.0", "react": "19.1.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"react-i18next": "^15.5.2", "react-i18next": "^15.5.3",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -24,10 +24,10 @@
"@kit/supabase": "workspace:*", "@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"next": "15.3.3", "next": "15.3.3",
"react": "19.1.0", "react": "19.1.0",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -27,11 +27,11 @@
"@kit/supabase": "workspace:*", "@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"next": "15.3.3", "next": "15.3.3",
"react": "19.1.0", "react": "19.1.0",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -20,7 +20,7 @@
"@kit/shared": "workspace:*", "@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/wordpress": "workspace:*", "@kit/wordpress": "workspace:*",
"@types/node": "^22.15.30" "@types/node": "^24.0.1"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -26,10 +26,10 @@
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@types/node": "^22.15.30", "@types/node": "^24.0.1",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"react": "19.1.0", "react": "19.1.0",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -20,8 +20,8 @@
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@types/node": "^22.15.30", "@types/node": "^24.0.1",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"wp-types": "^4.68.0" "wp-types": "^4.68.0"
}, },
"typesVersions": { "typesVersions": {

View File

@@ -23,7 +23,7 @@
"@kit/team-accounts": "workspace:*", "@kit/team-accounts": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -13,7 +13,7 @@
".": "./src/index.ts" ".": "./src/index.ts"
}, },
"dependencies": { "dependencies": {
"@react-email/components": "0.0.41" "@react-email/components": "0.0.42"
}, },
"devDependencies": { "devDependencies": {
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",

View File

@@ -20,7 +20,7 @@
"nanoid": "^5.1.5" "nanoid": "^5.1.5"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^5.1.0", "@hookform/resolvers": "^5.1.1",
"@kit/billing-gateway": "workspace:*", "@kit/billing-gateway": "workspace:*",
"@kit/email-templates": "workspace:*", "@kit/email-templates": "workspace:*",
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
@@ -35,18 +35,17 @@
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.6", "@tanstack/react-query": "5.80.7",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"lucide-react": "^0.513.0", "lucide-react": "^0.514.0",
"next": "15.3.3", "next": "15.3.3",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"react-i18next": "^15.5.2", "react-i18next": "^15.5.3",
"sonner": "^2.0.5", "zod": "^3.25.63"
"zod": "^3.25.56"
}, },
"prettier": "@kit/prettier-config", "prettier": "@kit/prettier-config",
"typesVersions": { "typesVersions": {

View File

@@ -91,7 +91,7 @@ export function PersonalAccountDropdown({
aria-label="Open your profile menu" aria-label="Open your profile menu"
data-test={'account-dropdown-trigger'} data-test={'account-dropdown-trigger'}
className={cn( className={cn(
'animate-in fade-in focus:outline-primary flex cursor-pointer items-center duration-500 group-data-[minimized=true]:px-0', 'animate-in group/trigger fade-in focus:outline-primary flex cursor-pointer items-center border border-dashed group-data-[minimized=true]:px-0',
className ?? '', className ?? '',
{ {
['active:bg-secondary/50 items-center gap-4 rounded-md' + ['active:bg-secondary/50 items-center gap-4 rounded-md' +
@@ -100,7 +100,9 @@ export function PersonalAccountDropdown({
)} )}
> >
<ProfileAvatar <ProfileAvatar
className={'rounded-md'} className={
'group-hover/trigger:border-background/50 rounded-md border border-transparent transition-colors'
}
fallbackClassName={'rounded-md border'} fallbackClassName={'rounded-md border'}
displayName={displayName ?? user?.email ?? ''} displayName={displayName ?? user?.email ?? ''}
pictureUrl={personalAccountData?.picture_url} pictureUrl={personalAccountData?.picture_url}

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import type { Provider } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -17,6 +19,7 @@ import { Trans } from '@kit/ui/trans';
import { usePersonalAccountData } from '../../hooks/use-personal-account-data'; import { usePersonalAccountData } from '../../hooks/use-personal-account-data';
import { AccountDangerZone } from './account-danger-zone'; import { AccountDangerZone } from './account-danger-zone';
import { UpdateEmailFormContainer } from './email/update-email-form-container'; import { UpdateEmailFormContainer } from './email/update-email-form-container';
import { LinkAccountsList } from './link-accounts';
import { MultiFactorAuthFactorsList } from './mfa/multi-factor-auth-list'; import { MultiFactorAuthFactorsList } from './mfa/multi-factor-auth-list';
import { UpdatePasswordFormContainer } from './password/update-password-container'; import { UpdatePasswordFormContainer } from './password/update-password-container';
import { UpdateAccountDetailsFormContainer } from './update-account-details-form-container'; import { UpdateAccountDetailsFormContainer } from './update-account-details-form-container';
@@ -29,11 +32,14 @@ export function PersonalAccountSettingsContainer(
features: { features: {
enableAccountDeletion: boolean; enableAccountDeletion: boolean;
enablePasswordUpdate: boolean; enablePasswordUpdate: boolean;
enableAccountLinking: boolean;
}; };
paths: { paths: {
callback: string; callback: string;
}; };
providers: Provider[];
}>, }>,
) { ) {
const supportsLanguageSelection = useSupportMultiLanguage(); const supportsLanguageSelection = useSupportMultiLanguage();
@@ -150,6 +156,24 @@ export function PersonalAccountSettingsContainer(
</CardContent> </CardContent>
</Card> </Card>
<If condition={props.features.enableAccountLinking}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:linkedAccounts'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:linkedAccountsDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<LinkAccountsList providers={props.providers} />
</CardContent>
</Card>
</If>
<If condition={props.features.enableAccountDeletion}> <If condition={props.features.enableAccountDeletion}>
<Card className={'border-destructive'}> <Card className={'border-destructive'}>
<CardHeader> <CardHeader>

View File

@@ -6,7 +6,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon } from '@radix-ui/react-icons'; import { CheckIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation'; import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -21,6 +20,7 @@ import {
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { UpdateEmailSchema } from '../../../schema/update-email.schema'; import { UpdateEmailSchema } from '../../../schema/update-email.schema';

View File

@@ -1 +1,2 @@
export * from './account-settings-container'; export * from './account-settings-container';
export * from './link-accounts';

View File

@@ -0,0 +1 @@
export * from './link-accounts-list';

View File

@@ -0,0 +1,211 @@
'use client';
import type { Provider, UserIdentity } from '@supabase/supabase-js';
import { CheckCircle } from 'lucide-react';
import { useLinkIdentityWithProvider } from '@kit/supabase/hooks/use-link-identity-with-provider';
import { useUnlinkUserIdentity } from '@kit/supabase/hooks/use-unlink-user-identity';
import { useUserIdentities } from '@kit/supabase/hooks/use-user-identities';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
import { Separator } from '@kit/ui/separator';
import { toast } from '@kit/ui/sonner';
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
export function LinkAccountsList(props: { providers: Provider[] }) {
const unlinkMutation = useUnlinkUserIdentity();
const linkMutation = useLinkIdentityWithProvider();
const {
identities,
hasMultipleIdentities,
isProviderConnected,
isLoading: isLoadingIdentities,
} = useUserIdentities();
// Only show providers from the allowed list that aren't already connected
const availableProviders = props.providers.filter(
(provider) => !isProviderConnected(provider),
);
// Show all connected identities, even if their provider isn't in the allowed providers list
const connectedIdentities = identities;
const handleUnlinkAccount = (identity: UserIdentity) => {
const promise = unlinkMutation.mutateAsync(identity);
toast.promise(promise, {
loading: <Trans i18nKey={'account:unlinkingAccount'} />,
success: <Trans i18nKey={'account:accountUnlinked'} />,
error: <Trans i18nKey={'account:unlinkAccountError'} />,
});
};
const handleLinkAccount = (provider: Provider) => {
const promise = linkMutation.mutateAsync(provider);
toast.promise(promise, {
loading: <Trans i18nKey={'account:linkingAccount'} />,
success: <Trans i18nKey={'account:accountLinked'} />,
error: <Trans i18nKey={'account:linkAccountError'} />,
});
};
if (isLoadingIdentities) {
return (
<div className="flex items-center justify-center py-8">
<Spinner className="h-6 w-6" />
</div>
);
}
return (
<div className="space-y-6">
{/* Linked Accounts Section */}
<If condition={connectedIdentities.length > 0}>
<div className="space-y-3">
<div>
<h3 className="text-foreground text-sm font-medium">
<Trans i18nKey={'account:linkedAccounts'} />
</h3>
<p className="text-muted-foreground text-xs">
<Trans i18nKey={'account:alreadyLinkedAccountsDescription'} />
</p>
</div>
<div className="flex flex-col space-y-2">
{connectedIdentities.map((identity) => (
<div
key={identity.id}
className="bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<OauthProviderLogoImage providerId={identity.provider} />
<div className="flex flex-col">
<span className="flex items-center gap-x-2 text-sm font-medium capitalize">
<CheckCircle className="h-3 w-3 text-green-500" />
<span>{identity.provider}</span>
</span>
<If condition={identity.identity_data?.email}>
<span className="text-muted-foreground text-xs">
{identity.identity_data?.email as string}
</span>
</If>
</div>
</div>
<If condition={hasMultipleIdentities}>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={unlinkMutation.isPending}
>
<If condition={unlinkMutation.isPending}>
<Spinner className="mr-2 h-3 w-3" />
</If>
<Trans i18nKey={'account:unlinkAccount'} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account:confirmUnlinkAccount'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={'account:unlinkAccountConfirmation'}
values={{ provider: identity.provider }}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleUnlinkAccount(identity)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<Trans i18nKey={'account:unlinkAccount'} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</If>
</div>
))}
</div>
</div>
</If>
{/* Available Accounts Section */}
<If condition={availableProviders.length > 0}>
<Separator />
<div className="space-y-3">
<div>
<h3 className="text-foreground text-sm font-medium">
<Trans i18nKey={'account:availableAccounts'} />
</h3>
<p className="text-muted-foreground text-xs">
<Trans i18nKey={'account:availableAccountsDescription'} />
</p>
</div>
<div className="flex flex-col space-y-2">
{availableProviders.map((provider) => (
<button
key={provider}
className="hover:bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3 transition-colors"
onClick={() => handleLinkAccount(provider)}
>
<div className="flex items-center gap-3">
<OauthProviderLogoImage providerId={provider} />
<span className="text-sm font-medium capitalize">
{provider}
</span>
</div>
</button>
))}
</div>
</div>
</If>
<If
condition={
connectedIdentities.length === 0 && availableProviders.length === 0
}
>
<div className="text-muted-foreground py-8 text-center">
<Trans i18nKey={'account:noAccountsAvailable'} />
</div>
</If>
</div>
);
}

View File

@@ -8,7 +8,6 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ShieldCheck, X } from 'lucide-react'; import { ShieldCheck, X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors'; import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useSupabase } from '@kit/supabase/hooks/use-supabase';
@@ -27,6 +26,7 @@ import {
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { toast } from '@kit/ui/sonner';
import { Spinner } from '@kit/ui/spinner'; import { Spinner } from '@kit/ui/spinner';
import { import {
Table, Table,

View File

@@ -8,7 +8,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeftIcon } from 'lucide-react'; import { ArrowLeftIcon } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useSupabase } from '@kit/supabase/hooks/use-supabase';
@@ -40,6 +39,7 @@ import {
InputOTPSeparator, InputOTPSeparator,
InputOTPSlot, InputOTPSlot,
} from '@kit/ui/input-otp'; } from '@kit/ui/input-otp';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { refreshAuthSession } from '../../../server/personal-accounts-server-actions'; import { refreshAuthSession } from '../../../server/personal-accounts-server-actions';

View File

@@ -1,9 +1,7 @@
'use client'; 'use client';
import { useUser } from '@kit/supabase/hooks/use-user'; import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert } from '@kit/ui/alert';
import { LoadingOverlay } from '@kit/ui/loading-overlay'; import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans';
import { UpdatePasswordForm } from './update-password-form'; import { UpdatePasswordForm } from './update-password-form';
@@ -18,25 +16,9 @@ export function UpdatePasswordFormContainer(
return <LoadingOverlay fullPage={false} />; return <LoadingOverlay fullPage={false} />;
} }
if (!user) { if (!user?.email) {
return null; return null;
} }
const canUpdatePassword = user.identities?.some(
(item) => item.provider === `email`,
);
if (!canUpdatePassword) {
return <WarnCannotUpdatePasswordAlert />;
}
return <UpdatePasswordForm callbackPath={props.callbackPath} user={user} />; return <UpdatePasswordForm callbackPath={props.callbackPath} user={user} />;
} }
function WarnCannotUpdatePasswordAlert() {
return (
<Alert variant={'warning'}>
<Trans i18nKey={'account:cannotUpdatePassword'} />
</Alert>
);
}

View File

@@ -9,7 +9,6 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation'; import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -26,6 +25,7 @@ import {
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label'; import { Label } from '@kit/ui/label';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { PasswordUpdateSchema } from '../../../schema/update-password.schema'; import { PasswordUpdateSchema } from '../../../schema/update-password.schema';

View File

@@ -1,7 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
@@ -14,6 +13,7 @@ import {
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { useUpdateAccountData } from '../../hooks/use-update-account'; import { useUpdateAccountData } from '../../hooks/use-update-account';

View File

@@ -5,11 +5,11 @@ import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js'; import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { ImageUploader } from '@kit/ui/image-uploader'; import { ImageUploader } from '@kit/ui/image-uploader';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data'; import { useRevalidatePersonalAccountDataQuery } from '../../hooks/use-personal-account-data';

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
export const LinkEmailPasswordSchema = z
.object({
email: z.string().email(),
password: z.string().min(8).max(99),
repeatPassword: z.string().min(8).max(99),
})
.refine((values) => values.password === values.repeatPassword, {
path: ['repeatPassword'],
message: `account:passwordNotMatching`,
});

View File

@@ -10,7 +10,7 @@
}, },
"prettier": "@kit/prettier-config", "prettier": "@kit/prettier-config",
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^5.1.0", "@hookform/resolvers": "^5.1.1",
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/next": "workspace:*", "@kit/next": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
@@ -21,15 +21,15 @@
"@makerkit/data-loader-supabase-core": "^0.0.10", "@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5", "@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.6", "@tanstack/react-query": "5.80.7",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"lucide-react": "^0.513.0", "lucide-react": "^0.514.0",
"next": "15.3.3", "next": "15.3.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",

View File

@@ -16,10 +16,11 @@
"./mfa": "./src/mfa.ts", "./mfa": "./src/mfa.ts",
"./captcha/client": "./src/captcha/client/index.ts", "./captcha/client": "./src/captcha/client/index.ts",
"./captcha/server": "./src/captcha/server/index.ts", "./captcha/server": "./src/captcha/server/index.ts",
"./resend-email-link": "./src/components/resend-auth-link-form.tsx" "./resend-email-link": "./src/components/resend-auth-link-form.tsx",
"./oauth-provider-logo-image": "./src/components/oauth-provider-logo-image.tsx"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^5.1.0", "@hookform/resolvers": "^5.1.1",
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*", "@kit/shared": "workspace:*",
@@ -29,14 +30,14 @@
"@marsidev/react-turnstile": "^1.1.0", "@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.6", "@tanstack/react-query": "5.80.7",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"lucide-react": "^0.513.0", "lucide-react": "^0.514.0",
"next": "15.3.3", "next": "15.3.3",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"react-i18next": "^15.5.2", "react-i18next": "^15.5.3",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"prettier": "@kit/prettier-config", "prettier": "@kit/prettier-config",
"typesVersions": { "typesVersions": {

View File

@@ -15,7 +15,7 @@ export function AuthLayoutShell({
{Logo ? <Logo /> : null} {Logo ? <Logo /> : null}
<div <div
className={`bg-background flex w-full max-w-[23rem] flex-col gap-y-6 rounded-lg px-6 md:w-8/12 md:px-8 md:py-6 lg:w-5/12 lg:px-8 xl:w-4/12 xl:gap-y-8 xl:py-8`} className={`bg-background flex w-full max-w-[23rem] flex-col gap-y-6 rounded-lg px-6 md:w-8/12 md:px-8 md:py-6 lg:w-5/12 lg:px-8 xl:w-4/12 xl:py-8`}
> >
{children} {children}
</div> </div>

View File

@@ -1,34 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
function AuthLinkRedirect(props: { redirectPath?: string }) {
const params = useSearchParams();
const redirectPath = params?.get('redirectPath') ?? props.redirectPath ?? '/';
useRedirectOnSignIn(redirectPath);
return null;
}
export default AuthLinkRedirect;
function useRedirectOnSignIn(redirectPath: string) {
const supabase = useSupabase();
const router = useRouter();
useEffect(() => {
const { data } = supabase.auth.onAuthStateChange((_, session) => {
if (session) {
router.push(redirectPath);
}
});
return () => data.subscription.unsubscribe();
}, [supabase, router, redirectPath]);
}

View File

@@ -1,6 +1,5 @@
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
import { OauthProviderLogoImage } from './oauth-provider-logo-image';
export function AuthProviderButton({ export function AuthProviderButton({
providerId, providerId,

View File

@@ -0,0 +1,87 @@
'use client';
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { UserCheck } from 'lucide-react';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
interface ExistingAccountHintProps {
signInPath?: string;
className?: string;
}
// we force dynamic import to avoid hydration errors
export const ExistingAccountHint = dynamic(
async () => ({ default: ExistingAccountHintImpl }),
{
ssr: false,
},
);
export function ExistingAccountHintImpl({
signInPath = '/auth/sign-in',
className,
}: ExistingAccountHintProps) {
const { hasLastMethod, methodType, providerName, isOAuth } =
useLastAuthMethod();
// Get the appropriate method description for the hint
// This must be called before any conditional returns to follow Rules of Hooks
const methodDescription = useMemo(() => {
if (isOAuth && providerName) {
return providerName;
}
switch (methodType) {
case 'password':
return 'email and password';
case 'otp':
return 'email verification';
case 'magic_link':
return 'email link';
default:
return 'another method';
}
}, [methodType, isOAuth, providerName]);
// Don't show anything until loaded or if no last method
if (!hasLastMethod) {
return null;
}
return (
<If condition={Boolean(methodDescription)}>
<Alert
data-test={'existing-account-hint'}
variant="info"
className={className}
>
<UserCheck className="h-4 w-4" />
<AlertDescription>
<Trans
i18nKey="auth:existingAccountHint"
values={{ method: methodDescription }}
components={{
method: <span className="font-medium" />,
signInLink: (
<Link
href={signInPath}
className="font-medium underline hover:no-underline"
/>
),
}}
/>
</AlertDescription>
</Alert>
</If>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import { Lightbulb } from 'lucide-react';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
interface LastAuthMethodHintProps {
className?: string;
}
// we force dynamic import to avoid hydration errors
export const LastAuthMethodHint = dynamic(
async () => ({ default: LastAuthMethodHintImpl }),
{
ssr: false,
},
);
function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
const { hasLastMethod, methodType, providerName, isOAuth } =
useLastAuthMethod();
// Get the appropriate translation key based on the method - memoized
// This must be called before any conditional returns to follow Rules of Hooks
const methodKey = useMemo(() => {
switch (methodType) {
case 'password':
return 'auth:methodPassword';
case 'otp':
return 'auth:methodOtp';
case 'magic_link':
return 'auth:methodMagicLink';
case 'oauth':
return 'auth:methodOauth';
default:
return null;
}
}, [methodType]);
// Don't show anything until loaded or if no last method
if (!hasLastMethod) {
return null;
}
if (!methodKey) {
return null; // If method is not recognized, don't render anything
}
return (
<div
data-test="last-auth-method-hint"
className={`text-muted-foreground/80 flex items-center justify-center gap-2 text-xs ${className || ''}`}
>
<Lightbulb className="h-3 w-3" />
<span>
<Trans i18nKey="auth:lastUsedMethodPrefix" />{' '}
<If condition={isOAuth && Boolean(providerName)}>
<Trans
i18nKey="auth:methodOauthWithProvider"
values={{ provider: providerName }}
components={{
provider: <span className="text-muted-foreground font-medium" />,
}}
/>
</If>
<If condition={!isOAuth || !providerName}>
<span className="text-muted-foreground font-medium">
<Trans i18nKey={methodKey} />
</span>
</If>
</span>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
import { useAppEvents } from '@kit/shared/events'; import { useAppEvents } from '@kit/shared/events';
@@ -21,9 +20,11 @@ import {
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from '../captcha/client'; import { useCaptchaToken } from '../captcha/client';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import { TermsAndConditionsFormField } from './terms-and-conditions-form-field'; import { TermsAndConditionsFormField } from './terms-and-conditions-form-field';
export function MagicLinkAuthContainer({ export function MagicLinkAuthContainer({
@@ -46,6 +47,7 @@ export function MagicLinkAuthContainer({
const { t } = useTranslation(); const { t } = useTranslation();
const signInWithOtpMutation = useSignInWithOtp(); const signInWithOtpMutation = useSignInWithOtp();
const appEvents = useAppEvents(); const appEvents = useAppEvents();
const { recordAuthMethod } = useLastAuthMethod();
const form = useForm({ const form = useForm({
resolver: zodResolver( resolver: zodResolver(
@@ -77,6 +79,8 @@ export function MagicLinkAuthContainer({
}, },
}); });
recordAuthMethod('magic_link', { email });
if (shouldCreateUser) { if (shouldCreateUser) {
appEvents.emit({ appEvents.emit({
type: 'user.signedUp', type: 'user.signedUp',
@@ -90,7 +94,7 @@ export function MagicLinkAuthContainer({
toast.promise(promise, { toast.promise(promise, {
loading: t('auth:sendingEmailLink'), loading: t('auth:sendingEmailLink'),
success: t(`auth:sendLinkSuccessToast`), success: t(`auth:sendLinkSuccessToast`),
error: t(`auth:errors.link`), error: t(`auth:errors.linkTitle`),
}); });
resetCaptchaToken(); resetCaptchaToken();
@@ -103,11 +107,11 @@ export function MagicLinkAuthContainer({
return ( return (
<Form {...form}> <Form {...form}>
<form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}> <form className={'w-full'} onSubmit={form.handleSubmit(onSubmit)}>
<If condition={signInWithOtpMutation.error}>
<ErrorAlert />
</If>
<div className={'flex flex-col space-y-4'}> <div className={'flex flex-col space-y-4'}>
<If condition={signInWithOtpMutation.error}>
<ErrorAlert />
</If>
<FormField <FormField
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
@@ -171,11 +175,11 @@ function ErrorAlert() {
<ExclamationTriangleIcon className={'h-4'} /> <ExclamationTriangleIcon className={'h-4'} />
<AlertTitle> <AlertTitle>
<Trans i18nKey={'auth:errors.generic'} /> <Trans i18nKey={'auth:errors.linkTitle'} />
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
<Trans i18nKey={'auth:errors.link'} /> <Trans i18nKey={'auth:errors.linkDescription'} />
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );

View File

@@ -12,6 +12,7 @@ import { If } from '@kit/ui/if';
import { LoadingOverlay } from '@kit/ui/loading-overlay'; import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import { AuthErrorAlert } from './auth-error-alert'; import { AuthErrorAlert } from './auth-error-alert';
import { AuthProviderButton } from './auth-provider-button'; import { AuthProviderButton } from './auth-provider-button';
@@ -42,6 +43,7 @@ export const OauthProviders: React.FC<{
}; };
}> = (props) => { }> = (props) => {
const signInWithProviderMutation = useSignInWithProvider(); const signInWithProviderMutation = useSignInWithProvider();
const { recordAuthMethod } = useLastAuthMethod();
// we make the UI "busy" until the next page is fully loaded // we make the UI "busy" until the next page is fully loaded
const loading = signInWithProviderMutation.isPending; const loading = signInWithProviderMutation.isPending;
@@ -105,9 +107,15 @@ export const OauthProviders: React.FC<{
}, },
} satisfies SignInWithOAuthCredentials; } satisfies SignInWithOAuthCredentials;
return onSignInWithProvider(() => return onSignInWithProvider(async () => {
signInWithProviderMutation.mutateAsync(credentials), const result =
); await signInWithProviderMutation.mutateAsync(credentials);
// Record successful OAuth sign-in
recordAuthMethod('oauth', { provider });
return result;
});
}} }}
> >
<Trans <Trans

View File

@@ -0,0 +1,249 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
import { useVerifyOtp } from '@kit/supabase/hooks/use-verify-otp';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@kit/ui/input-otp';
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
import { useCaptchaToken } from '../captcha/client';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import { AuthErrorAlert } from './auth-error-alert';
const EmailSchema = z.object({ email: z.string().email() });
const OtpSchema = z.object({ token: z.string().min(6).max(6) });
export function OtpSignInContainer({
onSignIn,
shouldCreateUser,
}: {
onSignIn?: (userId?: string) => void;
shouldCreateUser: boolean;
}) {
const verifyMutation = useVerifyOtp();
const router = useRouter();
const params = useSearchParams();
const { recordAuthMethod } = useLastAuthMethod();
const otpForm = useForm({
resolver: zodResolver(OtpSchema.merge(EmailSchema)),
defaultValues: {
token: '',
email: '',
},
});
const email = useWatch({
control: otpForm.control,
name: 'email',
});
const isEmailStep = !email;
const handleVerifyOtp = async ({
token,
email,
}: {
token: string;
email: string;
}) => {
const result = await verifyMutation.mutateAsync({
type: 'email',
email,
token,
});
// Record successful OTP sign-in
recordAuthMethod('otp', { email });
if (onSignIn) {
return onSignIn(result?.user?.id);
}
// on sign ups we redirect to the app home
if (shouldCreateUser) {
const next = params.get('next') ?? '/home';
router.replace(next);
}
};
if (isEmailStep) {
return (
<OtpEmailForm
shouldCreateUser={shouldCreateUser}
onSendOtp={(email) => {
otpForm.setValue('email', email, {
shouldValidate: true,
});
}}
/>
);
}
return (
<Form {...otpForm}>
<form
className="flex w-full flex-col items-center space-y-8"
onSubmit={otpForm.handleSubmit(handleVerifyOtp)}
>
<AuthErrorAlert error={verifyMutation.error} />
<FormField
name="token"
render={({ field }) => (
<FormItem>
<FormControl>
<InputOTP
maxLength={6}
{...field}
disabled={verifyMutation.isPending}
>
<InputOTPGroup>
<InputOTPSlot index={0} data-slot="0" />
<InputOTPSlot index={1} data-slot="1" />
<InputOTPSlot index={2} data-slot="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} data-slot="3" />
<InputOTPSlot index={4} data-slot="4" />
<InputOTPSlot index={5} data-slot="5" />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
<Trans i18nKey="common:otp.enterCodeFromEmail" />
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full flex-col gap-y-2">
<Button
type="submit"
disabled={verifyMutation.isPending}
data-test="otp-verify-button"
>
{verifyMutation.isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Trans i18nKey="common:otp.verifying" />
</>
) : (
<Trans i18nKey="common:otp.verifyCode" />
)}
</Button>
<Button
type="button"
variant="ghost"
disabled={verifyMutation.isPending}
onClick={() => {
otpForm.setValue('email', '', {
shouldValidate: true,
});
}}
>
<Trans i18nKey="common:otp.requestNewCode" />
</Button>
</div>
</form>
</Form>
);
}
function OtpEmailForm({
shouldCreateUser,
onSendOtp,
}: {
shouldCreateUser: boolean;
onSendOtp: (email: string) => void;
}) {
const { captchaToken, resetCaptchaToken } = useCaptchaToken();
const signInMutation = useSignInWithOtp();
const emailForm = useForm({
resolver: zodResolver(EmailSchema),
defaultValues: { email: '' },
});
const handleSendOtp = async ({ email }: z.infer<typeof EmailSchema>) => {
await signInMutation.mutateAsync({
email,
options: { captchaToken, shouldCreateUser },
});
resetCaptchaToken();
onSendOtp(email);
};
return (
<Form {...emailForm}>
<form
className="flex flex-col gap-y-4"
onSubmit={emailForm.handleSubmit(handleSendOtp)}
>
<AuthErrorAlert error={signInMutation.error} />
<FormField
name="email"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
required
type="email"
placeholder="email@example.com"
data-test="otp-email-input"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={signInMutation.isPending}
data-test="otp-send-button"
>
{signInMutation.isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Trans i18nKey="common:otp.sendingCode" />
</>
) : (
<Trans i18nKey="common:otp.sendVerificationCode" />
)}
</Button>
</form>
</Form>
);
}

View File

@@ -7,6 +7,7 @@ import type { z } from 'zod';
import { useSignInWithEmailPassword } from '@kit/supabase/hooks/use-sign-in-with-email-password'; import { useSignInWithEmailPassword } from '@kit/supabase/hooks/use-sign-in-with-email-password';
import { useCaptchaToken } from '../captcha/client'; import { useCaptchaToken } from '../captcha/client';
import { useLastAuthMethod } from '../hooks/use-last-auth-method';
import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema'; import type { PasswordSignInSchema } from '../schemas/password-sign-in.schema';
import { AuthErrorAlert } from './auth-error-alert'; import { AuthErrorAlert } from './auth-error-alert';
import { PasswordSignInForm } from './password-sign-in-form'; import { PasswordSignInForm } from './password-sign-in-form';
@@ -18,6 +19,7 @@ export function PasswordSignInContainer({
}) { }) {
const { captchaToken, resetCaptchaToken } = useCaptchaToken(); const { captchaToken, resetCaptchaToken } = useCaptchaToken();
const signInMutation = useSignInWithEmailPassword(); const signInMutation = useSignInWithEmailPassword();
const { recordAuthMethod } = useLastAuthMethod();
const isLoading = signInMutation.isPending; const isLoading = signInMutation.isPending;
const isRedirecting = signInMutation.isSuccess; const isRedirecting = signInMutation.isSuccess;
@@ -29,6 +31,9 @@ export function PasswordSignInContainer({
options: { captchaToken }, options: { captchaToken },
}); });
// Record successful password sign-in
recordAuthMethod('password', { email: credentials.email });
if (onSignIn) { if (onSignIn) {
const userId = data?.user?.id; const userId = data?.user?.id;
@@ -40,7 +45,13 @@ export function PasswordSignInContainer({
resetCaptchaToken(); resetCaptchaToken();
} }
}, },
[captchaToken, onSignIn, resetCaptchaToken, signInMutation], [
captchaToken,
onSignIn,
resetCaptchaToken,
signInMutation,
recordAuthMethod,
],
); );
return ( return (

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import type { Provider } from '@supabase/supabase-js'; import type { Provider } from '@supabase/supabase-js';
@@ -9,8 +11,10 @@ import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator'; import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { LastAuthMethodHint } from './last-auth-method-hint';
import { MagicLinkAuthContainer } from './magic-link-auth-container'; import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers'; import { OauthProviders } from './oauth-providers';
import { OtpSignInContainer } from './otp-sign-in-container';
import { PasswordSignInContainer } from './password-sign-in-container'; import { PasswordSignInContainer } from './password-sign-in-container';
export function SignInMethodsContainer(props: { export function SignInMethodsContainer(props: {
@@ -25,6 +29,7 @@ export function SignInMethodsContainer(props: {
providers: { providers: {
password: boolean; password: boolean;
magicLink: boolean; magicLink: boolean;
otp: boolean;
oAuth: Provider[]; oAuth: Provider[];
}; };
}) { }) {
@@ -34,7 +39,7 @@ export function SignInMethodsContainer(props: {
? new URL(props.paths.callback, window?.location.origin).toString() ? new URL(props.paths.callback, window?.location.origin).toString()
: ''; : '';
const onSignIn = () => { const onSignIn = useCallback(() => {
// if the user has an invite token, we should join the team // if the user has an invite token, we should join the team
if (props.inviteToken) { if (props.inviteToken) {
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
@@ -50,10 +55,12 @@ export function SignInMethodsContainer(props: {
// otherwise, we should redirect to the return path // otherwise, we should redirect to the return path
router.replace(returnPath); router.replace(returnPath);
} }
}; }, [props.inviteToken, props.paths.joinTeam, props.paths.returnPath, router]);
return ( return (
<> <>
<LastAuthMethodHint />
<If condition={props.providers.password}> <If condition={props.providers.password}>
<PasswordSignInContainer onSignIn={onSignIn} /> <PasswordSignInContainer onSignIn={onSignIn} />
</If> </If>
@@ -66,6 +73,10 @@ export function SignInMethodsContainer(props: {
/> />
</If> </If>
<If condition={props.providers.otp}>
<OtpSignInContainer shouldCreateUser={false} onSignIn={onSignIn} />
</If>
<If condition={props.providers.oAuth.length}> <If condition={props.providers.oAuth.length}>
<div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">

View File

@@ -8,8 +8,10 @@ import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator'; import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { ExistingAccountHint } from './existing-account-hint';
import { MagicLinkAuthContainer } from './magic-link-auth-container'; import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers'; import { OauthProviders } from './oauth-providers';
import { OtpSignInContainer } from './otp-sign-in-container';
import { EmailPasswordSignUpContainer } from './password-sign-up-container'; import { EmailPasswordSignUpContainer } from './password-sign-up-container';
export function SignUpMethodsContainer(props: { export function SignUpMethodsContainer(props: {
@@ -21,6 +23,7 @@ export function SignUpMethodsContainer(props: {
providers: { providers: {
password: boolean; password: boolean;
magicLink: boolean; magicLink: boolean;
otp: boolean;
oAuth: Provider[]; oAuth: Provider[];
}; };
@@ -32,6 +35,9 @@ export function SignUpMethodsContainer(props: {
return ( return (
<> <>
{/* Show hint if user might already have an account */}
<ExistingAccountHint />
<If condition={props.inviteToken}> <If condition={props.inviteToken}>
<InviteAlert /> <InviteAlert />
</If> </If>
@@ -44,6 +50,10 @@ export function SignUpMethodsContainer(props: {
/> />
</If> </If>
<If condition={props.providers.otp}>
<OtpSignInContainer shouldCreateUser={true} />
</If>
<If condition={props.providers.magicLink}> <If condition={props.providers.magicLink}>
<MagicLinkAuthContainer <MagicLinkAuthContainer
inviteToken={props.inviteToken} inviteToken={props.inviteToken}

View File

@@ -0,0 +1,78 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import type { AuthMethod, LastAuthMethod } from '../utils/last-auth-method';
import {
clearLastAuthMethod,
getLastAuthMethod,
saveLastAuthMethod,
} from '../utils/last-auth-method';
export function useLastAuthMethod() {
const [lastAuthMethod, setLastAuthMethod] = useState<LastAuthMethod | null>(
getLastAuthMethod(),
);
// Save a new auth method - memoized to prevent unnecessary re-renders
const recordAuthMethod = useCallback(
(
method: AuthMethod,
options?: {
provider?: string;
email?: string;
},
) => {
const authMethod: LastAuthMethod = {
method,
provider: options?.provider,
email: options?.email,
timestamp: Date.now(),
};
saveLastAuthMethod(authMethod);
setLastAuthMethod(authMethod);
},
[],
);
// Clear the stored auth method - memoized to prevent unnecessary re-renders
const clearAuthMethod = useCallback(() => {
clearLastAuthMethod();
setLastAuthMethod(null);
}, []);
// Compute derived values using useMemo for performance
const derivedData = useMemo(() => {
if (!lastAuthMethod) {
return {
hasLastMethod: false,
methodType: null,
providerName: null,
isOAuth: false,
};
}
const isOAuth = lastAuthMethod.method === 'oauth';
const providerName =
isOAuth && lastAuthMethod.provider
? lastAuthMethod.provider.charAt(0).toUpperCase() +
lastAuthMethod.provider.slice(1)
: null;
return {
hasLastMethod: true,
methodType: lastAuthMethod.method,
providerName,
isOAuth,
};
}, [lastAuthMethod]);
return {
lastAuthMethod,
recordAuthMethod,
clearAuthMethod,
...derivedData,
};
}

View File

@@ -7,6 +7,8 @@ import { useRouter } from 'next/navigation';
import { useAppEvents } from '@kit/shared/events'; import { useAppEvents } from '@kit/shared/events';
import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password'; import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password';
import { useLastAuthMethod } from './use-last-auth-method';
type SignUpCredentials = { type SignUpCredentials = {
email: string; email: string;
password: string; password: string;
@@ -33,6 +35,7 @@ export function usePasswordSignUpFlow({
const router = useRouter(); const router = useRouter();
const signUpMutation = useSignUpWithEmailAndPassword(); const signUpMutation = useSignUpWithEmailAndPassword();
const appEvents = useAppEvents(); const appEvents = useAppEvents();
const { recordAuthMethod } = useLastAuthMethod();
const signUp = useCallback( const signUp = useCallback(
async (credentials: SignUpCredentials) => { async (credentials: SignUpCredentials) => {
@@ -47,6 +50,9 @@ export function usePasswordSignUpFlow({
captchaToken, captchaToken,
}); });
// Record last auth method
recordAuthMethod('password', { email: credentials.email });
// emit event to track sign up // emit event to track sign up
appEvents.emit({ appEvents.emit({
type: 'user.signedUp', type: 'user.signedUp',
@@ -58,6 +64,7 @@ export function usePasswordSignUpFlow({
// Update URL with success status. This is useful for password managers // Update URL with success status. This is useful for password managers
// to understand that the form was submitted successfully. // to understand that the form was submitted successfully.
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set('status', 'success'); url.searchParams.set('status', 'success');
router.replace(url.pathname + url.search); router.replace(url.pathname + url.search);
@@ -66,6 +73,7 @@ export function usePasswordSignUpFlow({
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw error; throw error;
} finally { } finally {
resetCaptchaToken?.(); resetCaptchaToken?.();

View File

@@ -1,2 +1,3 @@
export * from './components/sign-in-methods-container'; export * from './components/sign-in-methods-container';
export * from './components/otp-sign-in-container';
export * from './schemas/password-sign-in.schema'; export * from './schemas/password-sign-in.schema';

View File

@@ -0,0 +1,71 @@
'use client';
import { isBrowser } from '@kit/shared/utils';
// Key for localStorage
const LAST_AUTH_METHOD_KEY = 'auth_last_method';
// Types of authentication methods
export type AuthMethod = 'password' | 'otp' | 'magic_link' | 'oauth';
export interface LastAuthMethod {
method: AuthMethod;
provider?: string; // For OAuth providers (e.g., 'google', 'github')
email?: string; // Store email for method-specific hints
timestamp: number;
}
/**
* Save the last used authentication method to localStorage
*/
export function saveLastAuthMethod(authMethod: LastAuthMethod): void {
try {
localStorage.setItem(LAST_AUTH_METHOD_KEY, JSON.stringify(authMethod));
} catch (error) {
console.warn('Failed to save last auth method:', error);
}
}
/**
* Get the last used authentication method from localStorage
*/
export function getLastAuthMethod() {
if (!isBrowser()) {
return null;
}
try {
const stored = localStorage.getItem(LAST_AUTH_METHOD_KEY);
if (!stored) {
return null;
}
const parsed = JSON.parse(stored) as LastAuthMethod;
// Check if the stored method is older than 30 days
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
if (parsed.timestamp < thirtyDaysAgo) {
localStorage.removeItem(LAST_AUTH_METHOD_KEY);
return null;
}
return parsed;
} catch (error) {
console.warn('Failed to get last auth method:', error);
return null;
}
}
/**
* Clear the last used authentication method from localStorage
*/
export function clearLastAuthMethod() {
try {
localStorage.removeItem(LAST_AUTH_METHOD_KEY);
} catch (error) {
console.warn('Failed to clear last auth method:', error);
}
}

View File

@@ -20,12 +20,12 @@
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.6", "@tanstack/react-query": "5.80.7",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"lucide-react": "^0.513.0", "lucide-react": "^0.514.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "^15.5.2" "react-i18next": "^15.5.3"
}, },
"prettier": "@kit/prettier-config", "prettier": "@kit/prettier-config",
"typesVersions": { "typesVersions": {

View File

@@ -18,7 +18,7 @@
"nanoid": "^5.1.5" "nanoid": "^5.1.5"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^5.1.0", "@hookform/resolvers": "^5.1.1",
"@kit/accounts": "workspace:*", "@kit/accounts": "workspace:*",
"@kit/billing-gateway": "workspace:*", "@kit/billing-gateway": "workspace:*",
"@kit/email-templates": "workspace:*", "@kit/email-templates": "workspace:*",
@@ -33,20 +33,19 @@
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.6", "@tanstack/react-query": "5.80.7",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.513.0", "lucide-react": "^0.514.0",
"next": "15.3.3", "next": "15.3.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"react-i18next": "^15.5.2", "react-i18next": "^15.5.3",
"sonner": "^2.0.5", "zod": "^3.25.63"
"zod": "^3.25.56"
}, },
"prettier": "@kit/prettier-config", "prettier": "@kit/prettier-config",
"typesVersions": { "typesVersions": {

View File

@@ -6,7 +6,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Plus, X } from 'lucide-react'; import { Plus, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
@@ -27,6 +26,7 @@ import {
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,

View File

@@ -5,10 +5,10 @@ import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js'; import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { ImageUploader } from '@kit/ui/image-uploader'; import { ImageUploader } from '@kit/ui/image-uploader';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
const AVATARS_BUCKET = 'account_image'; const AVATARS_BUCKET = 'account_image';

View File

@@ -7,7 +7,6 @@ import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {
@@ -19,6 +18,7 @@ import {
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { TeamNameFormSchema } from '../../schema/update-team-name.schema'; import { TeamNameFormSchema } from '../../schema/update-team-name.schema';

View File

@@ -20,15 +20,15 @@
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*", "@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@tanstack/react-query": "5.80.6", "@tanstack/react-query": "5.80.7",
"next": "15.3.3", "next": "15.3.3",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-i18next": "^15.5.2" "react-i18next": "^15.5.3"
}, },
"dependencies": { "dependencies": {
"i18next": "25.2.1", "i18next": "25.2.1",
"i18next-browser-languagedetector": "8.1.0", "i18next-browser-languagedetector": "8.2.0",
"i18next-resources-to-backend": "^1.2.1" "i18next-resources-to-backend": "^1.2.1"
}, },
"typesVersions": { "typesVersions": {

View File

@@ -20,8 +20,8 @@
"@kit/resend": "workspace:*", "@kit/resend": "workspace:*",
"@kit/shared": "workspace:*", "@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@types/node": "^22.15.30", "@types/node": "^24.0.1",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -21,7 +21,7 @@
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@types/nodemailer": "6.4.17", "@types/nodemailer": "6.4.17",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -17,8 +17,8 @@
"@kit/mailers-shared": "workspace:*", "@kit/mailers-shared": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@types/node": "^22.15.30", "@types/node": "^24.0.1",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -16,7 +16,7 @@
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -24,9 +24,9 @@
"@kit/sentry": "workspace:*", "@kit/sentry": "workspace:*",
"@kit/shared": "workspace:*", "@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"react": "19.1.0", "react": "19.1.0",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -24,9 +24,9 @@
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"react": "19.1.0", "react": "19.1.0",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -10,11 +10,9 @@ export function BaselimeProvider({
children, children,
apiKey, apiKey,
enableWebVitals, enableWebVitals,
ErrorPage,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
apiKey?: string; apiKey?: string;
enableWebVitals?: boolean; enableWebVitals?: boolean;
ErrorPage?: React.ReactElement;
}>) { }>) {
const key = apiKey ?? process.env.NEXT_PUBLIC_BASELIME_KEY ?? ''; const key = apiKey ?? process.env.NEXT_PUBLIC_BASELIME_KEY ?? '';
@@ -28,11 +26,7 @@ export function BaselimeProvider({
} }
return ( return (
<BaselimeRum <BaselimeRum apiKey={key} enableWebVitals={enableWebVitals}>
apiKey={key}
enableWebVitals={enableWebVitals}
fallback={ErrorPage ?? null}
>
<MonitoringProvider>{children}</MonitoringProvider> <MonitoringProvider>{children}</MonitoringProvider>
</BaselimeRum> </BaselimeRum>
); );

View File

@@ -17,7 +17,7 @@
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"react": "19.1.0" "react": "19.1.0"
}, },
"typesVersions": { "typesVersions": {

View File

@@ -16,7 +16,7 @@
"./config/server": "./src/sentry.client.server.ts" "./config/server": "./src/sentry.client.server.ts"
}, },
"dependencies": { "dependencies": {
"@sentry/nextjs": "^9.27.0", "@sentry/nextjs": "^9.28.1",
"import-in-the-middle": "1.14.0" "import-in-the-middle": "1.14.0"
}, },
"devDependencies": { "devDependencies": {
@@ -24,7 +24,7 @@
"@kit/monitoring-core": "workspace:*", "@kit/monitoring-core": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"react": "19.1.0" "react": "19.1.0"
}, },
"typesVersions": { "typesVersions": {

View File

@@ -22,7 +22,7 @@
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"next": "15.3.3", "next": "15.3.3",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -14,7 +14,7 @@
"./components": "./src/components/index.ts" "./components": "./src/components/index.ts"
}, },
"devDependencies": { "devDependencies": {
"@hookform/resolvers": "^5.1.0", "@hookform/resolvers": "^5.1.1",
"@kit/email-templates": "workspace:*", "@kit/email-templates": "workspace:*",
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/mailers": "workspace:*", "@kit/mailers": "workspace:*",
@@ -26,12 +26,12 @@
"@kit/ui": "workspace:*", "@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -20,7 +20,7 @@
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@types/react": "19.1.6" "@types/react": "19.1.8"
}, },
"dependencies": { "dependencies": {
"pino": "^9.7.0" "pino": "^9.7.0"

View File

@@ -26,12 +26,12 @@
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@supabase/ssr": "^0.6.1", "@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "2.50.0", "@supabase/supabase-js": "2.50.0",
"@tanstack/react-query": "5.80.6", "@tanstack/react-query": "5.80.7",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"next": "15.3.3", "next": "15.3.3",
"react": "19.1.0", "react": "19.1.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -0,0 +1,50 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from './use-supabase';
import { USER_IDENTITIES_QUERY_KEY } from './use-user-identities';
interface Credentials {
email: string;
password: string;
redirectTo: string;
}
export function useLinkIdentityWithEmailPassword() {
const client = useSupabase();
const queryClient = useQueryClient();
const mutationKey = ['auth', 'link-email-password'];
const mutationFn = async (credentials: Credentials) => {
const { email, password, redirectTo } = credentials;
const { error } = await client.auth.updateUser(
{
email,
password,
data: {
// This is used to indicate that the user has a password set
// because Supabase does not add the identity after setting a password
// if the user was created with oAuth
hasPassword: true,
},
},
{ emailRedirectTo: redirectTo },
);
if (error) {
throw error.message ?? error;
}
await client.auth.refreshSession();
};
return useMutation({
mutationKey,
mutationFn,
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: USER_IDENTITIES_QUERY_KEY,
});
},
});
}

View File

@@ -0,0 +1,37 @@
import type { Provider } from '@supabase/supabase-js';
import { useMutation } from '@tanstack/react-query';
import { useSupabase } from './use-supabase';
export function useLinkIdentityWithProvider(
props: {
redirectToPath?: string;
} = {},
) {
const client = useSupabase();
const mutationKey = ['auth', 'link-identity'];
const mutationFn = async (provider: Provider) => {
const origin = window.location.origin;
const redirectToPath = props.redirectToPath ?? '/home/settings';
const url = new URL('/auth/callback', origin);
url.searchParams.set('redirectTo', redirectToPath);
const { error: linkError } = await client.auth.linkIdentity({
provider,
options: {
redirectTo: url.toString(),
},
});
if (linkError) {
throw linkError.message ?? linkError;
}
await client.auth.refreshSession();
};
return useMutation({ mutationKey, mutationFn });
}

View File

@@ -0,0 +1,22 @@
import type { UserIdentity } from '@supabase/supabase-js';
import { useMutation } from '@tanstack/react-query';
import { useSupabase } from './use-supabase';
export function useUnlinkIdentity() {
const client = useSupabase();
const mutationKey = ['auth', 'unlink-identity'];
const mutationFn = async (identity: UserIdentity) => {
const { error } = await client.auth.unlinkIdentity(identity);
if (error) {
throw error.message ?? error;
}
await client.auth.refreshSession();
};
return useMutation({ mutationKey, mutationFn });
}

View File

@@ -0,0 +1,30 @@
import type { UserIdentity } from '@supabase/supabase-js';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useSupabase } from './use-supabase';
import { USER_IDENTITIES_QUERY_KEY } from './use-user-identities';
export function useUnlinkUserIdentity() {
const supabase = useSupabase();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (identity: UserIdentity) => {
// Unlink the identity
const { error } = await supabase.auth.unlinkIdentity(identity);
if (error) {
throw error;
}
return identity;
},
onSuccess: () => {
// Invalidate and refetch user identities
return queryClient.invalidateQueries({
queryKey: USER_IDENTITIES_QUERY_KEY,
});
},
});
}

View File

@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import type { Provider } from '@supabase/supabase-js';
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from './use-supabase';
export const USER_IDENTITIES_QUERY_KEY = ['user-identities'];
export function useUserIdentities() {
const supabase = useSupabase();
const {
data: identities = [],
isLoading,
error,
refetch,
} = useQuery({
queryKey: USER_IDENTITIES_QUERY_KEY,
queryFn: async () => {
const { data, error } = await supabase.auth.getUserIdentities();
if (error) {
throw error;
}
return data.identities;
},
});
const connectedProviders = useMemo(() => {
return identities.map((identity) => identity.provider);
}, [identities]);
const hasMultipleIdentities = identities.length > 1;
const getIdentityByProvider = (provider: Provider) => {
return identities.find((identity) => identity.provider === provider);
};
const isProviderConnected = (provider: Provider) => {
return connectedProviders.includes(provider);
};
return {
identities,
connectedProviders,
hasMultipleIdentities,
getIdentityByProvider,
isProviderConnected,
isLoading,
error,
refetch,
};
}

View File

@@ -9,7 +9,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.1.0", "@hookform/resolvers": "^5.1.1",
"@radix-ui/react-accordion": "1.2.11", "@radix-ui/react-accordion": "1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
@@ -33,19 +33,19 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "1.1.1", "cmdk": "1.1.1",
"input-otp": "1.4.2", "input-otp": "1.4.2",
"lucide-react": "^0.513.0", "lucide-react": "^0.514.0",
"react-top-loading-bar": "3.0.2", "react-top-loading-bar": "3.0.2",
"recharts": "2.15.3", "recharts": "2.15.3",
"tailwind-merge": "^3.3.0" "tailwind-merge": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
"@kit/eslint-config": "workspace:*", "@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*", "@kit/tsconfig": "workspace:*",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@tanstack/react-query": "5.80.6", "@tanstack/react-query": "5.80.7",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -55,12 +55,12 @@
"prettier": "^3.5.3", "prettier": "^3.5.3",
"react-day-picker": "^9.7.0", "react-day-picker": "^9.7.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"react-i18next": "^15.5.2", "react-i18next": "^15.5.3",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"tailwindcss": "4.1.8", "tailwindcss": "4.1.10",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"zod": "^3.25.56" "zod": "^3.25.63"
}, },
"prettier": "@kit/prettier-config", "prettier": "@kit/prettier-config",
"imports": { "imports": {
@@ -129,7 +129,8 @@
"./multi-step-form": "./src/makerkit/multi-step-form.tsx", "./multi-step-form": "./src/makerkit/multi-step-form.tsx",
"./app-breadcrumbs": "./src/makerkit/app-breadcrumbs.tsx", "./app-breadcrumbs": "./src/makerkit/app-breadcrumbs.tsx",
"./empty-state": "./src/makerkit/empty-state.tsx", "./empty-state": "./src/makerkit/empty-state.tsx",
"./marketing": "./src/makerkit/marketing/index.tsx" "./marketing": "./src/makerkit/marketing/index.tsx",
"./oauth-provider-logo-image": "./src/makerkit/oauth-provider-logo-image.tsx"
}, },
"typesVersions": { "typesVersions": {
"*": { "*": {

View File

@@ -33,8 +33,8 @@ export function OauthProviderLogoImage({
function getOAuthProviderLogos(): Record<string, string | React.ReactNode> { function getOAuthProviderLogos(): Record<string, string | React.ReactNode> {
return { return {
password: <AtSign className={'s-[18px]'} />, email: <AtSign className={'size-[18px]'} />,
phone: <Phone className={'s-[18px]'} />, phone: <Phone className={'size-[18x]'} />,
google: '/images/oauth/google.webp', google: '/images/oauth/google.webp',
facebook: '/images/oauth/facebook.webp', facebook: '/images/oauth/facebook.webp',
github: '/images/oauth/github.webp', github: '/images/oauth/github.webp',

View File

@@ -46,11 +46,7 @@ function PageWithSidebar(props: PageProps) {
> >
{MobileNavigation} {MobileNavigation}
<div <div className={'bg-background flex flex-1 flex-col px-4 lg:px-0'}>
className={
'bg-background flex flex-1 flex-col px-4 lg:px-0'
}
>
{Children} {Children}
</div> </div>
</div> </div>

2112
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"eslint-config-next": "15.3.3", "eslint-config-next": "15.3.3",
"eslint-config-turbo": "^2.5.4", "eslint-config-turbo": "^2.5.4",
"typescript-eslint": "8.33.1" "typescript-eslint": "8.34.0"
}, },
"devDependencies": { "devDependencies": {
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",