From 6b2139fa5dfeb8137c11c7ac56899e226daf1c25 Mon Sep 17 00:00:00 2001
From: Nate Kelley
Date: Wed, 24 Sep 2025 17:24:23 -0600
Subject: [PATCH] reset user flow
---
apps/supabase/config.toml | 8 ++-
apps/supabase/templates/confirmation.html | 4 +-
apps/supabase/templates/invite.html | 4 +-
apps/supabase/templates/recovery.html | 4 +-
.../components/features/auth/PolicyCheck.tsx | 2 +-
.../features/auth/ResetPasswordForm.tsx | 65 ++++++++++++++-----
.../supabase/getSupabaseUserClient.ts | 9 ++-
.../integrations/supabase/resetPassword.ts | 20 +++---
apps/web/src/routes/auth.reset-password.tsx | 54 ++++++++++++++-
9 files changed, 132 insertions(+), 38 deletions(-)
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 @@
@@ -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 ;
}