Hint recent authentication method (#5255)

This commit is contained in:
Kamil Kisiela 2024-07-24 10:27:46 +02:00 committed by GitHub
parent 95304fb338
commit 214a795eb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 292 additions and 182 deletions

View file

@ -0,0 +1,31 @@
import { useCallback, useMemo, useState } from 'react';
import Cookies from 'js-cookie';
const LAST_USED_AUTH_METHOD_KEY = 'hive:last-used-auth-method';
type AuthProvider = 'github' | 'google' | 'email' | 'okta' | 'oidc';
export function updateLastAuthMethod(provider: AuthProvider) {
Cookies.set(LAST_USED_AUTH_METHOD_KEY, provider);
}
export function useLastAuthMethod() {
const [authProvider, setAuthProvider] = useState<AuthProvider | null>(
(Cookies.get(LAST_USED_AUTH_METHOD_KEY) as AuthProvider) ?? null,
);
const updateAuthProvider = useCallback(
(provider: AuthProvider) => {
setAuthProvider(provider);
Cookies.set(LAST_USED_AUTH_METHOD_KEY, provider);
},
[setAuthProvider],
);
const api = useMemo(
() => [authProvider, updateAuthProvider] as const,
[authProvider, updateAuthProvider],
);
return api;
}

View file

@ -1,5 +1,6 @@
import { getAuthorisationURLWithQueryParamsAndSetState } from 'supertokens-auth-react/recipe/thirdpartyemailpassword';
import { env } from '@/env/frontend';
import { updateLastAuthMethod } from './last-auth-method';
/**
* utility for starting the login flow manually without clicking a button
@ -25,5 +26,7 @@ export const startAuthFlowForProvider = async (
frontendRedirectURI: `${env.appBaseUrl}/auth/callback/${thirdPartyId}${redirectPart}`,
});
updateLastAuthMethod(thirdPartyId);
window.location.assign(authUrl);
};

View file

@ -1,6 +1,7 @@
import { UserInput } from 'supertokens-auth-react/lib/build/recipe/thirdpartyemailpassword/types';
import { getAuthorisationURLWithQueryParamsAndSetState } from 'supertokens-auth-react/recipe/thirdpartyemailpassword';
import { env } from '@/env/frontend';
import { updateLastAuthMethod } from './last-auth-method';
export const createThirdPartyEmailPasswordReactOIDCProvider = () => ({
id: 'oidc',
@ -79,6 +80,8 @@ export const startAuthFlowForOIDCProvider = async (oidcId: string) => {
},
});
updateLastAuthMethod('oidc');
// Redirects to the OIDC provider
window.location.assign(authUrl);
};

View file

@ -23,14 +23,48 @@ import {
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Meta } from '@/components/ui/meta';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast';
import { useLastAuthMethod } from '@/lib/supertokens/last-auth-method';
import { startAuthFlowForProvider } from '@/lib/supertokens/start-auth-flow-for-provider';
import { enabledProviders, isProviderEnabled } from '@/lib/supertokens/thirdparty';
import { exhaustiveGuard } from '@/lib/utils';
import { cn, exhaustiveGuard } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
import { Slot } from '@radix-ui/react-slot';
import { useMutation } from '@tanstack/react-query';
import { Link, Navigate, useRouter } from '@tanstack/react-router';
export function SignInButton(props: {
children: React.ReactNode;
previousSignIn: boolean;
variant?: 'outline' | 'default';
tooltipClassName?: string;
}) {
if (props.previousSignIn) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Slot
className={cn(
'animate-shimmer bg-[length:200%_100%] transition-colors',
props.variant === 'outline'
? 'bg-[linear-gradient(110deg,transparent,48%,#202020,52%,transparent)]'
: 'bg-[linear-gradient(110deg,transparent,30%,#a9a9a9,70%,transparent)]',
)}
>
{props.children}
</Slot>
</TooltipTrigger>
<TooltipContent className={cn('text-muted bg-white', props.tooltipClassName)} side="top">
You signed in with it last time.
</TooltipContent>
</Tooltip>
);
}
return <>{props.children}</>;
}
const SignInFormSchema = z.object({
email: z
.string({
@ -44,6 +78,7 @@ type SignInFormValues = z.infer<typeof SignInFormSchema>;
export function AuthSignInPage(props: { redirectToPath: string }) {
const session = useSessionContext();
const [lastAuthMethod, setLastAuthMethod] = useLastAuthMethod();
const router = useRouter();
const { toast } = useToast();
const emailPasswordSignIn = useMutation({
@ -53,6 +88,7 @@ export function AuthSignInPage(props: { redirectToPath: string }) {
switch (status) {
case 'OK': {
setLastAuthMethod('email');
void router.navigate({
to: props.redirectToPath,
});
@ -157,99 +193,112 @@ export function AuthSignInPage(props: { redirectToPath: string }) {
<AuthCardHeader title="Login" description="Sign in to your account" />
<AuthCardContent>
<AuthCardStack>
<Form {...form}>
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormLabel>Password</FormLabel>
<Link
tabIndex={-1}
to="/auth/reset-password"
search={{
email: form.getValues().email || undefined,
redirectToPath: props.redirectToPath,
}}
className="ml-auto inline-block text-sm underline"
>
Forgot your password?
</Link>
</div>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isPending}>
{emailPasswordSignIn.data?.status === 'OK'
? 'Redirecting...'
: emailPasswordSignIn.isPending
? 'Signing in...'
: 'Sign in'}
</Button>
</form>
</Form>
{enabledProviders.length ? <AuthOrSeparator /> : null}
{isProviderEnabled('google') ? (
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('google')}
disabled={isPending}
>
<SiGoogle className="mr-4 size-4" /> Login with Google
</Button>
) : null}
{isProviderEnabled('github') ? (
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('github')}
disabled={isPending}
>
<SiGithub className="mr-4 size-4" /> Login with Github
</Button>
) : null}
{isProviderEnabled('okta') ? (
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('okta')}
disabled={isPending}
>
<SiOkta className="mr-4 size-4" /> Login with Okta
</Button>
) : null}
{isProviderEnabled('oidc') ? (
<Button asChild variant="outline" className="w-full" disabled={isPending}>
<Link
to="/auth/sso"
search={{
redirectToPath: props.redirectToPath,
}}
>
<FaRegUserCircle className="mr-4 size-4" /> Login with SSO
</Link>
</Button>
) : null}
<TooltipProvider delayDuration={200}>
<Form {...form}>
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormLabel>Password</FormLabel>
<Link
tabIndex={-1}
to="/auth/reset-password"
search={{
email: form.getValues().email || undefined,
redirectToPath: props.redirectToPath,
}}
className="ml-auto inline-block text-sm underline"
>
Forgot your password?
</Link>
</div>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SignInButton previousSignIn={lastAuthMethod === 'email'}>
<Button type="submit" className="w-full" disabled={isPending}>
{emailPasswordSignIn.data?.status === 'OK'
? 'Redirecting...'
: emailPasswordSignIn.isPending
? 'Signing in...'
: 'Sign in'}
</Button>
</SignInButton>
</form>
</Form>
{enabledProviders.length ? <AuthOrSeparator /> : null}
{isProviderEnabled('google') ? (
<SignInButton variant="outline" previousSignIn={lastAuthMethod === 'google'}>
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('google')}
disabled={isPending}
>
<SiGoogle className="mr-4 size-4" /> Login with Google
</Button>
</SignInButton>
) : null}
{isProviderEnabled('github') ? (
<SignInButton variant="outline" previousSignIn={lastAuthMethod === 'github'}>
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('github')}
disabled={isPending}
>
<SiGithub className="mr-4 size-4" /> Login with Github
</Button>
</SignInButton>
) : null}
{isProviderEnabled('okta') ? (
<SignInButton variant="outline" previousSignIn={lastAuthMethod === 'okta'}>
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('okta')}
disabled={isPending}
>
<SiOkta className="mr-4 size-4" /> Login with Okta
</Button>
</SignInButton>
) : null}
{isProviderEnabled('oidc') ? (
<SignInButton variant="outline" previousSignIn={lastAuthMethod === 'oidc'}>
<Button asChild variant="outline" className="w-full" disabled={isPending}>
<Link
to="/auth/sso"
search={{
redirectToPath: props.redirectToPath,
}}
>
<FaRegUserCircle className="mr-4 size-4" /> Login with SSO
</Link>
</Button>
</SignInButton>
) : null}
</TooltipProvider>
</AuthCardStack>
<div className="mt-4 text-center text-sm">
Don't have an account?{' '}

View file

@ -24,14 +24,17 @@ import {
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Meta } from '@/components/ui/meta';
import { TooltipProvider } from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast';
import { env } from '@/env/frontend';
import { useLastAuthMethod } from '@/lib/supertokens/last-auth-method';
import { startAuthFlowForProvider } from '@/lib/supertokens/start-auth-flow-for-provider';
import { enabledProviders, isProviderEnabled } from '@/lib/supertokens/thirdparty';
import { exhaustiveGuard } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { Link, Navigate, useRouter } from '@tanstack/react-router';
import { SignInButton } from './auth-sign-in';
const SignUpFormSchema = z.object({
firstName: z.string({
@ -51,6 +54,7 @@ const SignUpFormSchema = z.object({
type SignUpFormValues = z.infer<typeof SignUpFormSchema>;
export function AuthSignUpPage(props: { redirectToPath: string }) {
const [lastAuthMethod] = useLastAuthMethod();
const router = useRouter();
const session = useSessionContext();
@ -199,17 +203,45 @@ export function AuthSignUpPage(props: { redirectToPath: string }) {
/>
<AuthCardContent>
<AuthCardStack>
<Form {...form}>
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid grid-cols-2 gap-4">
<TooltipProvider delayDuration={200}>
<Form {...form}>
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First name</FormLabel>
<FormControl>
<Input placeholder="Max" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last name</FormLabel>
<FormControl>
<Input placeholder="Robinson" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="firstName"
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>First name</FormLabel>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="Max" {...field} />
<Input placeholder="m@example.com" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -217,96 +249,78 @@ export function AuthSignUpPage(props: { redirectToPath: string }) {
/>
<FormField
control={form.control}
name="lastName"
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Last name</FormLabel>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="Robinson" {...field} />
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isPending}>
{signUp.isSuccess && signUp.data.status === 'OK' && isVerificationSettled
? 'Redirecting...'
: signUp.isPending
? 'Creating account...'
: 'Create an account'}
</Button>
</form>
</Form>
{enabledProviders.length ? <AuthOrSeparator /> : null}
{isProviderEnabled('google') ? (
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('google')}
disabled={isPending}
>
<SiGoogle className="mr-4 size-4" /> Sign up with Google
</Button>
) : null}
{isProviderEnabled('github') ? (
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('github')}
disabled={isPending}
>
<SiGithub className="mr-4 size-4" /> Sign up with Github
</Button>
) : null}
{isProviderEnabled('okta') ? (
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('okta')}
disabled={isPending}
>
<SiOkta className="mr-4 size-4" /> Sign up with Okta
</Button>
) : null}
{isProviderEnabled('oidc') ? (
<Button asChild variant="outline" className="w-full" disabled={isPending}>
<Link
to="/auth/sso"
search={{
redirectToPath: props.redirectToPath,
}}
>
<FaRegUserCircle className="mr-4 size-4" /> Sign up with SSO
</Link>
</Button>
) : null}
<Button type="submit" className="w-full" disabled={isPending}>
{signUp.isSuccess && signUp.data.status === 'OK' && isVerificationSettled
? 'Redirecting...'
: signUp.isPending
? 'Creating account...'
: 'Create an account'}
</Button>
</form>
</Form>
{enabledProviders.length ? <AuthOrSeparator /> : null}
{isProviderEnabled('google') ? (
<SignInButton previousSignIn={lastAuthMethod === 'google'} variant="outline">
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('google')}
disabled={isPending}
>
<SiGoogle className="mr-4 size-4" /> Sign up with Google
</Button>
</SignInButton>
) : null}
{isProviderEnabled('github') ? (
<SignInButton previousSignIn={lastAuthMethod === 'github'} variant="outline">
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('github')}
disabled={isPending}
>
<SiGithub className="mr-4 size-4" /> Sign up with Github
</Button>
</SignInButton>
) : null}
{isProviderEnabled('okta') ? (
<SignInButton previousSignIn={lastAuthMethod === 'okta'} variant="outline">
<Button
variant="outline"
className="w-full"
onClick={() => thirdPartySignIn.mutate('okta')}
disabled={isPending}
>
<SiOkta className="mr-4 size-4" /> Sign up with Okta
</Button>
</SignInButton>
) : null}
{isProviderEnabled('oidc') ? (
<SignInButton previousSignIn={lastAuthMethod === 'oidc'} variant="outline">
<Button asChild variant="outline" className="w-full" disabled={isPending}>
<Link
to="/auth/sso"
search={{
redirectToPath: props.redirectToPath,
}}
>
<FaRegUserCircle className="mr-4 size-4" /> Sign up with SSO
</Link>
</Button>
</SignInButton>
) : null}
</TooltipProvider>
</AuthCardStack>
<div className="mt-4 text-center text-sm">
Already have an account?{' '}

View file

@ -227,6 +227,14 @@ module.exports = {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
shimmer: {
from: {
backgroundPosition: '0 0',
},
to: {
backgroundPosition: '-200% 0',
},
},
},
animation: {
// Dropdown menu
@ -255,6 +263,8 @@ module.exports = {
'toast-swipe-out-y': 'toast-swipe-out-y 100ms ease-out forwards',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
//
shimmer: 'shimmer 1.5s linear infinite',
},
minHeight: {
content: 'var(--content-height)',