reset user flow

This commit is contained in:
Nate Kelley 2025-09-24 17:24:23 -06:00
parent 31ce0574cd
commit 6b2139fa5d
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
9 changed files with 132 additions and 38 deletions

View File

@ -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.

View File

@ -122,7 +122,7 @@
</p>
<div style="text-align: center;">
<a href="{{ .RedirectTo }}" class="cta-button">Confirm Email Address</a>
<a href="{{ .ConfirmationURL }}" class="cta-button">Confirm Email Address</a>
</div>
<div class="security-note">
@ -131,7 +131,7 @@
<p class="message">
If the button doesn't work, you can copy and paste this link into your browser:<br>
<a href="{{ .RedirectTo }}" style="color: #007bff; word-break: break-all;">{{ .RedirectTo }}</a>
<a href="{{ .ConfirmationURL }}" style="color: #007bff; word-break: break-all;">{{ .ConfirmationURL }}</a>
</p>
</div>

View File

@ -122,7 +122,7 @@
</p>
<div style="text-align: center;">
<a href="{{ .RedirectTo }}" class="cta-button">Accept Invitation</a>
<a href="{{ .ConfirmationURL }}" class="cta-button">Accept Invitation</a>
</div>
<div class="security-note">
@ -131,7 +131,7 @@
<p class="message">
If the button doesn't work, you can copy and paste this link into your browser:<br>
<a href="{{ .RedirectTo }}" style="color: #007bff; word-break: break-all;">{{ .RedirectTo }}</a>
<a href="{{ .ConfirmationURL }}" style="color: #007bff; word-break: break-all;">{{ .ConfirmationURL }}</a>
</p>
</div>

View File

@ -122,7 +122,7 @@
</p>
<div style="text-align: center;">
<a href="{{ .RedirectTo }}" class="cta-button">Reset Password</a>
<a href="{{ .ConfirmationURL }}" class="cta-button">Reset Password</a>
</div>
<div class="security-note">
@ -131,7 +131,7 @@
<p class="message">
If the button doesn't work, you can copy and paste this link into your browser:<br>
<a href="{{ .RedirectTo }}" style="color: #007bff; word-break: break-all;">{{ .RedirectTo }}</a>
<a href="{{ .ConfirmationURL }}" style="color: #007bff; word-break: break-all;">{{ .ConfirmationURL }}</a>
</p>
<p class="message">

View File

@ -77,7 +77,7 @@ export const PolicyCheck: React.FC<{
},
{
text: 'Passwords match',
check: password === password2 || password2 === undefined,
check: (password && password === password2) || password2 === undefined,
},
];

View File

@ -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<User, 'email'>;
}> = ({ 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 (
<div className="flex h-full flex-col items-center justify-center gap-4">
@ -100,6 +120,12 @@ export const ResetPasswordForm: React.FC<{
autoComplete="new-password"
/>
{errorMessage && (
<Text size="xs" variant="danger">
{errorMessage}
</Text>
)}
<PolicyCheck
password={password}
password2={password2}
@ -127,6 +153,13 @@ export const ResetPasswordForm: React.FC<{
<SuccessCard
title="Password reset successfully"
message={`Navigating to app in ${countdown} seconds`}
extra={
<Link to="/app/home" reloadDocument replace>
<Button block variant="black">
Go to app now
</Button>
</Link>
}
/>
</div>
)}

View File

@ -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;
};

View File

@ -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;

View File

@ -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<null | Pick<User, 'email' | 'is_anonymous'>>(
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 <CircleSpinnerLoaderContainer />;
}
if (email || !supabaseUser || supabaseUser.is_anonymous) {
return <ResetEmailForm queryEmail={email || ''} />;
}