diff --git a/apps/supabase/config.toml b/apps/supabase/config.toml index 1ae219f81..e5c592424 100644 --- a/apps/supabase/config.toml +++ b/apps/supabase/config.toml @@ -83,9 +83,13 @@ enabled = true enabled = true # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used # in emails. -site_url = "http://127.0.0.1:3000" +site_url = "http://localhost:3000" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. -additional_redirect_urls = ["https://127.0.0.1:3000"] +additional_redirect_urls = [ + "https://localhost:3000", + "http://localhost:3000/auth/reset-password", + "https://localhost:3000/auth/reset-password" +] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 # If disabled, the refresh token will never expire. diff --git a/apps/supabase/templates/confirmation.html b/apps/supabase/templates/confirmation.html index e73c63cd0..28e908354 100644 --- a/apps/supabase/templates/confirmation.html +++ b/apps/supabase/templates/confirmation.html @@ -122,7 +122,7 @@

- Confirm Email Address + Confirm Email Address
@@ -131,7 +131,7 @@

If the button doesn't work, you can copy and paste this link into your browser:
- {{ .RedirectTo }} + {{ .ConfirmationURL }}

diff --git a/apps/supabase/templates/invite.html b/apps/supabase/templates/invite.html index 66fd065cd..c341da413 100644 --- a/apps/supabase/templates/invite.html +++ b/apps/supabase/templates/invite.html @@ -122,7 +122,7 @@

- Accept Invitation + Accept Invitation
@@ -131,7 +131,7 @@

If the button doesn't work, you can copy and paste this link into your browser:
- {{ .RedirectTo }} + {{ .ConfirmationURL }}

diff --git a/apps/supabase/templates/recovery.html b/apps/supabase/templates/recovery.html index fd0b0911b..6f4dc5168 100644 --- a/apps/supabase/templates/recovery.html +++ b/apps/supabase/templates/recovery.html @@ -122,7 +122,7 @@

- Reset Password + Reset Password
@@ -131,7 +131,7 @@

If the button doesn't work, you can copy and paste this link into your browser:
- {{ .RedirectTo }} + {{ .ConfirmationURL }}

diff --git a/apps/web/src/components/features/auth/PolicyCheck.tsx b/apps/web/src/components/features/auth/PolicyCheck.tsx index c42470d38..8d5eb6f8b 100644 --- a/apps/web/src/components/features/auth/PolicyCheck.tsx +++ b/apps/web/src/components/features/auth/PolicyCheck.tsx @@ -77,7 +77,7 @@ export const PolicyCheck: React.FC<{ }, { text: 'Passwords match', - check: password === password2 || password2 === undefined, + check: (password && password === password2) || password2 === undefined, }, ]; diff --git a/apps/web/src/components/features/auth/ResetPasswordForm.tsx b/apps/web/src/components/features/auth/ResetPasswordForm.tsx index 05904aca4..ed0306a4e 100644 --- a/apps/web/src/components/features/auth/ResetPasswordForm.tsx +++ b/apps/web/src/components/features/auth/ResetPasswordForm.tsx @@ -1,13 +1,14 @@ import type { User } from '@supabase/supabase-js'; -import { useRouter } from '@tanstack/react-router'; +import { useMutation } from '@tanstack/react-query'; +import { Link, useNavigate } from '@tanstack/react-router'; import type React from 'react'; import { useCallback, useState } from 'react'; import { useGetMyUserInfo } from '@/api/buster_rest/users'; import { Button } from '@/components/ui/buttons'; import { SuccessCard } from '@/components/ui/card/SuccessCard'; import { Input } from '@/components/ui/inputs'; -import { Title } from '@/components/ui/typography'; -import { useBusterNotifications } from '@/context/BusterNotifications'; +import { Text, Title } from '@/components/ui/typography'; +import { openErrorNotification } from '@/context/BusterNotifications'; import { resetPassword } from '@/integrations/supabase/resetPassword'; import { PolicyCheck } from './PolicyCheck'; @@ -15,15 +16,18 @@ export const ResetPasswordForm: React.FC<{ supabaseUser: Pick; }> = ({ supabaseUser }) => { const { data: busterUser } = useGetMyUserInfo(); - const router = useRouter(); - const [loading, setLoading] = useState(false); + const navigate = useNavigate(); const [resetSuccess, setResetSuccess] = useState(false); const email = busterUser?.user?.email || supabaseUser?.email; const [password, setPassword] = useState(''); const [password2, setPassword2] = useState(''); const [goodPassword, setGoodPassword] = useState(false); - const { openErrorMessage, openSuccessMessage } = useBusterNotifications(); const [countdown, setCountdown] = useState(5); + const [errorMessage, setErrorMessage] = useState(''); + + const { mutateAsync: resetPasswordMutation, isPending: loading } = useMutation({ + mutationFn: resetPassword, + }); const disabled = !goodPassword || loading || !password || !password2 || password !== password2; @@ -33,7 +37,7 @@ export const ResetPasswordForm: React.FC<{ setCountdown((prev) => { if (prev === 0) { clearInterval(interval); - router.navigate({ to: '/' }); + navigate({ to: '/app/home', replace: true, reloadDocument: true }); return 0; } return prev - 1; @@ -43,21 +47,37 @@ export const ResetPasswordForm: React.FC<{ }, []); const handleResetPassword = useCallback(async () => { - setLoading(true); + if (loading) { + return; + } + + if (!password || !password2 || password !== password2 || !goodPassword) { + openErrorNotification('Password and confirm password do not match'); + return; + } + setResetSuccess(false); + setErrorMessage(''); + try { - const res = await resetPassword({ data: { password } }); - setLoading(false); - if (res?.error) { - throw res; - } + await resetPasswordMutation({ data: { password } }); setResetSuccess(true); - openSuccessMessage('Password reset successfully'); startCountdown(); } catch (error) { - openErrorMessage(error as string); + openErrorNotification(error as string); + setErrorMessage(error as string); } - }, [resetPassword, password, openErrorMessage, openSuccessMessage]); + }, [ + resetPassword, + password, + password2, + goodPassword, + loading, + setResetSuccess, + startCountdown, + resetPasswordMutation, + openErrorNotification, + ]); return (

@@ -100,6 +120,12 @@ export const ResetPasswordForm: React.FC<{ autoComplete="new-password" /> + {errorMessage && ( + + {errorMessage} + + )} + + + + } />
)} diff --git a/apps/web/src/integrations/supabase/getSupabaseUserClient.ts b/apps/web/src/integrations/supabase/getSupabaseUserClient.ts index 6ada6ea85..c78e6f7c8 100644 --- a/apps/web/src/integrations/supabase/getSupabaseUserClient.ts +++ b/apps/web/src/integrations/supabase/getSupabaseUserClient.ts @@ -32,5 +32,12 @@ export const getSupabaseUser = async () => { const { data: userData } = isServer ? await getSupabaseUserServerFn() : await supabase.auth.getUser(); - return userData.user; + + const simplifiedUser = { + id: userData.user?.id ?? '', + email: userData.user?.email ?? '', + is_anonymous: userData.user?.is_anonymous ?? true, + }; + + return simplifiedUser; }; diff --git a/apps/web/src/integrations/supabase/resetPassword.ts b/apps/web/src/integrations/supabase/resetPassword.ts index ec7267b11..d1491eab2 100644 --- a/apps/web/src/integrations/supabase/resetPassword.ts +++ b/apps/web/src/integrations/supabase/resetPassword.ts @@ -1,21 +1,18 @@ import { createServerFn } from '@tanstack/react-start'; import { z } from 'zod'; import { env } from '@/env'; -import { ServerRoute as AuthCallbackRoute } from '../../routes/auth.callback'; import { Route as AuthResetPasswordRoute } from '../../routes/auth.reset-password'; +import { getSupabaseUser } from './getSupabaseUserClient'; import { getSupabaseServerClient } from './server'; export const resetPasswordEmailSend = createServerFn({ method: 'POST' }) .validator(z.object({ email: z.string().email() })) .handler(async ({ data: { email } }) => { const supabase = await getSupabaseServerClient(); + const url = env.VITE_PUBLIC_URL; - const authURLFull = `${url}${AuthResetPasswordRoute.to}`; - console.log('email', email); - console.log('authURLFull', authURLFull); - const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: authURLFull, }); @@ -32,16 +29,21 @@ export const resetPassword = createServerFn({ method: 'POST' }) .handler(async ({ data: { password } }) => { const supabase = await getSupabaseServerClient(); - const { data: user } = await supabase.auth.getUser(); + const supabaseUser = await getSupabaseUser(); - if (!user?.user) { - throw new Error('User not found'); + if (supabaseUser.is_anonymous) { + console.error('User is anonymous', supabaseUser); + throw new Error('User is anonymous'); + } + + if (!supabaseUser.email) { + console.error('User email not found', supabaseUser); } const { error } = await supabase.auth.updateUser({ password }); if (error) { - return { error: error.message }; + throw new Error(error.message); } return; diff --git a/apps/web/src/routes/auth.reset-password.tsx b/apps/web/src/routes/auth.reset-password.tsx index 85d21ea78..0dfdfb7b7 100644 --- a/apps/web/src/routes/auth.reset-password.tsx +++ b/apps/web/src/routes/auth.reset-password.tsx @@ -1,10 +1,16 @@ +import type { User } from '@supabase/supabase-js'; import { createFileRoute } from '@tanstack/react-router'; +import { useState } from 'react'; import { z } from 'zod'; import { ResetEmailForm } from '@/components/features/auth/ResetEmailForm'; import { ResetPasswordForm } from '@/components/features/auth/ResetPasswordForm'; -import { useGetSupabaseUser } from '@/context/Supabase'; +import { CircleSpinnerLoaderContainer } from '@/components/ui/loaders'; +import { useMount } from '@/hooks/useMount'; +import { getBrowserClient } from '@/integrations/supabase/client'; +import { getSupabaseUser } from '@/integrations/supabase/getSupabaseUserClient'; export const Route = createFileRoute('/auth/reset-password')({ + ssr: true, head: () => ({ meta: [ { title: 'Reset Password' }, @@ -13,17 +19,59 @@ export const Route = createFileRoute('/auth/reset-password')({ { name: 'og:description', content: 'Reset your Buster account password' }, ], }), + beforeLoad: async () => { + const supabaseUser = await getSupabaseUser(); + return { supabaseUser }; + }, + loader: async ({ context }) => { + const { supabaseUser } = context; + return { supabaseUser }; + }, + component: RouteComponent, validateSearch: z.object({ email: z.string().optional(), }), }); +const supabase = getBrowserClient(); + function RouteComponent() { const { email } = Route.useSearch(); - const supabaseUser = useGetSupabaseUser(); + const supabaseUserLoader = Route.useLoaderData(); + const [mounting, setMounting] = useState(true); - if (email || supabaseUser?.is_anonymous || !supabaseUser) { + const [supabaseUser, setSupabaseUser] = useState>( + supabaseUserLoader?.supabaseUser ?? null + ); + + useMount(() => { + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + setMounting(false); + if (session?.user) { + setSupabaseUser({ + email: session.user.email ?? '', + is_anonymous: session.user.is_anonymous ?? true, + }); + } + }); + + setTimeout(() => { + setMounting(false); + }, 500); + + return () => { + subscription.unsubscribe(); + }; + }); + + if (mounting) { + return ; + } + + if (email || !supabaseUser || supabaseUser.is_anonymous) { return ; }