Better login handling

This commit is contained in:
Nate Kelley 2025-09-03 14:10:44 -06:00
parent 47a5405a08
commit b77260cc67
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
6 changed files with 115 additions and 31 deletions

1
.gitignore vendored
View File

@ -89,3 +89,4 @@ drizzle/meta/
*.tsbuildinfo *.tsbuildinfo
/packages/aTest/.mastra /packages/aTest/.mastra
**/*.private.* **/*.private.*
apps/web-tss/.env.prod

View File

@ -8,6 +8,7 @@ import Github from '@/components/ui/icons/customIcons/Github';
import Google from '@/components/ui/icons/customIcons/Google'; import Google from '@/components/ui/icons/customIcons/Google';
import Microsoft from '@/components/ui/icons/customIcons/Microsoft'; import Microsoft from '@/components/ui/icons/customIcons/Microsoft';
import { Input } from '@/components/ui/inputs'; import { Input } from '@/components/ui/inputs';
import { AppTooltip } from '@/components/ui/tooltip';
import { Text, Title } from '@/components/ui/typography'; import { Text, Title } from '@/components/ui/typography';
import { env } from '@/env'; import { env } from '@/env';
import { useMemoizedFn } from '@/hooks/useMemoizedFn'; import { useMemoizedFn } from '@/hooks/useMemoizedFn';
@ -22,12 +23,20 @@ import { cn } from '@/lib/classMerge';
import { isValidEmail } from '@/lib/email'; import { isValidEmail } from '@/lib/email';
import { inputHasText } from '@/lib/text'; import { inputHasText } from '@/lib/text';
import { PolicyCheck } from './PolicyCheck'; import { PolicyCheck } from './PolicyCheck';
import { type LastUsedReturnType, useLastUsed } from './useLastUsed';
export const LoginForm: React.FC<{ redirectTo: string | null | undefined }> = ({ redirectTo }) => { export const LoginForm: React.FC<{
redirectTo: string | null | undefined;
isAnonymousUser?: boolean;
}> = ({ redirectTo }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const lastUsedProps = useLastUsed();
const [loading, setLoading] = useState<'google' | 'github' | 'azure' | 'email' | null>(null); const [loading, setLoading] = useState<'google' | 'github' | 'azure' | 'email' | null>(null);
const [errorMessages, setErrorMessages] = useState<string[]>([]); const [errorMessages, setErrorMessages] = useState<string[]>([]);
const [signUpFlow, setSignUpFlow] = useState(true); const [signUpFlow, setSignUpFlow] = useState(
lastUsedProps.isAnonymousUser && !env.VITE_PUBLIC_USER
);
const [signUpSuccess, setSignUpSuccess] = useState(false); const [signUpSuccess, setSignUpSuccess] = useState(false);
const onSignInWithUsernameAndPassword = useMemoizedFn( const onSignInWithUsernameAndPassword = useMemoizedFn(
@ -162,6 +171,7 @@ export const LoginForm: React.FC<{ redirectTo: string | null | undefined }> = ({
onSignInWithGoogle={onSignInWithGoogle} onSignInWithGoogle={onSignInWithGoogle}
onSignInWithGithub={onSignInWithGithub} onSignInWithGithub={onSignInWithGithub}
onSignInWithAzure={onSignInWithAzure} onSignInWithAzure={onSignInWithAzure}
lastUsedProps={lastUsedProps}
/> />
)} )}
</div> </div>
@ -179,6 +189,7 @@ const LoginOptions: React.FC<{
loading: 'google' | 'github' | 'azure' | 'email' | null; loading: 'google' | 'github' | 'azure' | 'email' | null;
setErrorMessages: (value: string[]) => void; setErrorMessages: (value: string[]) => void;
signUpFlow: boolean; signUpFlow: boolean;
lastUsedProps: LastUsedReturnType;
}> = ({ }> = ({
onSubmitClick, onSubmitClick,
onSignInWithGoogle, onSignInWithGoogle,
@ -189,11 +200,15 @@ const LoginOptions: React.FC<{
loading, loading,
setErrorMessages, setErrorMessages,
signUpFlow, signUpFlow,
lastUsedProps,
}) => { }) => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState(
const [password, setPassword] = useState(''); lastUsedProps.supabaseUser?.email || env.VITE_PUBLIC_USER || ''
);
const [password, setPassword] = useState(env.VITE_PUBLIC_USER_PASSWORD || '');
const [password2, setPassword2] = useState(''); const [password2, setPassword2] = useState('');
const [passwordCheck, setPasswordCheck] = useState(false); const [passwordCheck, setPasswordCheck] = useState(false);
const navigate = useNavigate();
const disableSubmitButton = const disableSubmitButton =
!inputHasText(password) || !inputHasText(password2) || password !== password2 || !passwordCheck; !inputHasText(password) || !inputHasText(password2) || password !== password2 || !passwordCheck;
@ -209,13 +224,13 @@ const LoginOptions: React.FC<{
useHotkeys( useHotkeys(
'meta+shift+b', 'meta+shift+b',
() => { (e) => {
e.preventDefault();
setSignUpFlow(false); setSignUpFlow(false);
const DEFAULT_CREDENTIALS = { const DEFAULT_CREDENTIALS = {
email: env.VITE_PUBLIC_USER || '', email: env.VITE_PUBLIC_USER || '',
password: env.VITE_PUBLIC_USER_PASSWORD || '', password: env.VITE_PUBLIC_USER_PASSWORD || '',
}; };
console.log('DEFAULT_CREDENTIALS', DEFAULT_CREDENTIALS);
onSubmitClick({ onSubmitClick({
email: DEFAULT_CREDENTIALS.email, email: DEFAULT_CREDENTIALS.email,
@ -228,7 +243,7 @@ const LoginOptions: React.FC<{
return ( return (
<> <>
<div className="flex flex-col items-center text-center"> <div className="flex flex-col items-center text-center">
<WelcomeText signUpFlow={signUpFlow} /> <WelcomeText signUpFlow={signUpFlow} lastUsedProps={lastUsedProps} />
</div> </div>
<div className="mt-6 mb-4 flex flex-col space-y-3"> <div className="mt-6 mb-4 flex flex-col space-y-3">
@ -358,6 +373,14 @@ const LoginOptions: React.FC<{
> >
{!signUpFlow ? 'Sign in' : 'Sign up'} {!signUpFlow ? 'Sign in' : 'Sign up'}
</Button> </Button>
{lastUsedProps.isCurrentlySignedIn && !signUpFlow && (
<Link to="/app/home">
<Button size={'tall'} block={true} type="button" className="truncate" variant="black">
Continue with {lastUsedProps.supabaseUser?.email}
</Button>
</Link>
)}
</form> </form>
<div className="mt-2 flex flex-col gap-y-2"> <div className="mt-2 flex flex-col gap-y-2">
@ -366,6 +389,7 @@ const LoginOptions: React.FC<{
setPassword2={setPassword2} setPassword2={setPassword2}
setSignUpFlow={setSignUpFlow} setSignUpFlow={setSignUpFlow}
signUpFlow={signUpFlow} signUpFlow={signUpFlow}
lastUsedProps={lastUsedProps}
/> />
{!signUpFlow && <ResetPasswordLink email={email} tabIndex={-7} />} {!signUpFlow && <ResetPasswordLink email={email} tabIndex={-7} />}
@ -401,13 +425,21 @@ const SignUpSuccess: React.FC<{
const WelcomeText: React.FC<{ const WelcomeText: React.FC<{
signUpFlow: boolean; signUpFlow: boolean;
}> = ({ signUpFlow }) => { lastUsedProps: LastUsedReturnType;
}> = ({ signUpFlow, lastUsedProps }) => {
const text = !signUpFlow ? 'Sign in' : 'Sign up for free'; const text = !signUpFlow ? 'Sign in' : 'Sign up for free';
const { isCurrentlySignedIn, supabaseUser } = lastUsedProps;
return ( return (
<Title className="mb-0" as="h1"> <div className="flex items-center justify-center gap-2">
{text} <AppTooltip
</Title> title={isCurrentlySignedIn ? `Currently signed in as ${supabaseUser?.email}` : undefined}
>
<Title className="mb-0" as="h1">
{text}
</Title>
</AppTooltip>
</div>
); );
}; };
@ -426,25 +458,30 @@ const AlreadyHaveAccount: React.FC<{
setPassword2: (value: string) => void; setPassword2: (value: string) => void;
setSignUpFlow: (value: boolean) => void; setSignUpFlow: (value: boolean) => void;
signUpFlow: boolean; signUpFlow: boolean;
}> = React.memo(({ setErrorMessages, setPassword2, setSignUpFlow, signUpFlow }) => { lastUsedProps: LastUsedReturnType;
return ( }> = React.memo(({ setErrorMessages, setPassword2, setSignUpFlow, signUpFlow, lastUsedProps }) => {
<div className="flex items-center justify-center gap-0.5"> const { isCurrentlySignedIn, supabaseUser } = lastUsedProps;
<Text className="" variant="secondary" size="xs">
{signUpFlow ? 'Already have an account? ' : "Don't already have an account?"}
</Text>
<Text return (
variant="primary" <div className="flex flex-col items-center justify-center gap-1.5">
size="xs" <div className="flex items-center justify-center gap-0.5">
className={cn('ml-1 cursor-pointer font-normal')} <Text className="" variant="secondary" size="xs">
onClick={() => { {signUpFlow ? 'Already have an account? ' : "Don't already have an account?"}
setErrorMessages([]); </Text>
setPassword2('');
setSignUpFlow(!signUpFlow); <Text
}} variant="primary"
> size="xs"
{!signUpFlow ? 'Sign up' : 'Sign in'} className={cn('ml-1 cursor-pointer font-normal')}
</Text> onClick={() => {
setErrorMessages([]);
setPassword2('');
setSignUpFlow(!signUpFlow);
}}
>
{!signUpFlow ? 'Sign up' : 'Sign in'}
</Text>
</div>
</div> </div>
); );
}); });

View File

@ -0,0 +1,34 @@
import { useMemo } from 'react';
import { useGetSupabaseUser } from '@/context/Supabase';
export type SignInType = 'google' | 'github' | 'azure' | 'email' | null;
export const useLastUsed = () => {
const supabaseUser = useGetSupabaseUser();
const provider = supabaseUser?.app_metadata?.provider;
const isAnonymousUser = !!supabaseUser?.is_anonymous;
const isCurrentlySignedIn = !isAnonymousUser && !!supabaseUser?.id;
const lastUsed: SignInType = useMemo(() => {
if (provider === 'google') {
return 'google';
} else if (provider === 'github') {
return 'github';
} else if (provider === 'azure') {
return 'azure';
} else if (provider === 'email' && !isAnonymousUser) {
return 'email';
}
return null;
}, [provider]);
return {
lastUsed,
isAnonymousUser,
isCurrentlySignedIn,
supabaseUser,
};
};
export type LastUsedReturnType = ReturnType<typeof useLastUsed>;

View File

@ -8,6 +8,9 @@ export type SupabaseContextType = {
user: { user: {
id: string; id: string;
is_anonymous: boolean; is_anonymous: boolean;
email: string;
identities: User['identities'];
app_metadata: User['app_metadata'];
}; };
accessToken: string; accessToken: string;
}; };
@ -20,7 +23,10 @@ const useSupabaseContextInternal = ({
accessToken: accessTokenProp, accessToken: accessTokenProp,
}: SupabaseContextType) => { }: SupabaseContextType) => {
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [supabaseUser, setSupabaseUser] = useState<Pick<User, 'id' | 'is_anonymous'> | null>(user); const [supabaseUser, setSupabaseUser] = useState<Pick<
User,
'id' | 'is_anonymous' | 'email' | 'identities' | 'app_metadata'
> | null>(user);
const [accessToken, setAccessToken] = useState(accessTokenProp); const [accessToken, setAccessToken] = useState(accessTokenProp);
const isAnonymousUser: boolean = !user?.id || user?.is_anonymous === true; const isAnonymousUser: boolean = !user?.id || user?.is_anonymous === true;

View File

@ -10,3 +10,8 @@ const stableIsAnonymousUserSelector = (state: SupabaseContextReturnType) => stat
export const useIsAnonymousSupabaseUser = () => { export const useIsAnonymousSupabaseUser = () => {
return useContextSelector(SupabaseContext, stableIsAnonymousUserSelector); return useContextSelector(SupabaseContext, stableIsAnonymousUserSelector);
}; };
const stableSupabaseUserSelector = (state: SupabaseContextReturnType) => state.supabaseUser;
export const useGetSupabaseUser = () => {
return useContextSelector(SupabaseContext, stableSupabaseUserSelector);
};

View File

@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'; import { ClientOnly, createFileRoute } from '@tanstack/react-router';
import { z } from 'zod'; import { z } from 'zod';
import { LoginForm } from '@/components/features/auth/LoginForm'; import { LoginForm } from '@/components/features/auth/LoginForm';
@ -21,6 +21,7 @@ export const Route = createFileRoute('/auth/login')({
function LoginComp() { function LoginComp() {
const { next } = Route.useSearch(); const { next } = Route.useSearch();
const cxt = Route.useRouteContext();
return <LoginForm redirectTo={next} />; return <LoginForm redirectTo={next} />;
} }