Add OTP sign-in option + Account Linking (#276)
* feat(accounts): allow linking email password * feat(auth): add OTP sign-in * refactor(accounts): remove 'sonner' dependency and update toast imports * feat(supabase): enable analytics and configure database seeding * feat(auth): update email templates and add OTP template * feat(auth): add last sign in method hints * feat(config): add devIndicators position to bottom-right * feat(auth): implement comprehensive last authentication method tracking tests
This commit is contained in:
committed by
GitHub
parent
856e9612c4
commit
9033155fcd
@@ -9,10 +9,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"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": [
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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
8
apps/web/supabase/templates/otp.html
Normal file
8
apps/web/supabase/templates/otp.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './account-settings-container';
|
export * from './account-settings-container';
|
||||||
|
export * from './link-accounts';
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './link-accounts-list';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
249
packages/features/auth/src/components/otp-sign-in-container.tsx
Normal file
249
packages/features/auth/src/components/otp-sign-in-container.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
78
packages/features/auth/src/hooks/use-last-auth-method.ts
Normal file
78
packages/features/auth/src/hooks/use-last-auth-method.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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?.();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
71
packages/features/auth/src/utils/last-auth-method.ts
Normal file
71
packages/features/auth/src/utils/last-auth-method.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
22
packages/supabase/src/hooks/use-unlink-identity.ts
Normal file
22
packages/supabase/src/hooks/use-unlink-identity.ts
Normal 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 });
|
||||||
|
}
|
||||||
30
packages/supabase/src/hooks/use-unlink-user-identity.ts
Normal file
30
packages/supabase/src/hooks/use-unlink-user-identity.ts
Normal 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
56
packages/supabase/src/hooks/use-user-identities.ts
Normal file
56
packages/supabase/src/hooks/use-user-identities.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
"*": {
|
"*": {
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -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
2112
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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:*",
|
||||||
|
|||||||
Reference in New Issue
Block a user