Skip to content

Commit c1794cb

Browse files
committed
refactor auth components
1 parent f4f921b commit c1794cb

16 files changed

+213
-67
lines changed

app/login/page.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { redirect } from 'next/navigation';
1010

1111
import { getUser } from '@/features/auth/actions/getUser';
12-
import { AuthLoginForm } from '@/features/auth/components/AuthLoginForm';
12+
import { AuthController } from '@/features/auth/components/AuthController';
1313
import { getMetadata } from '@/lib/next/metadata';
1414

1515
export const metadata = getMetadata('Login');
@@ -21,5 +21,5 @@ export default async function LoginPage({ searchParams }: PageProps<EmptyObj, {
2121
if (user) return redirect('/');
2222

2323
// Show login form for unauthorized users
24-
return <AuthLoginForm message={searchParams.message} />;
24+
return <AuthController message={searchParams.message} />;
2525
}

bun.lockb

6.93 KB
Binary file not shown.

components/ui/form/InputPassword.tsx

+7-4
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@
22

33
import { type InputProps, Input } from '@nextui-org/input';
44
import { EyeFilledIcon, EyeSlashFilledIcon } from '@nextui-org/shared-icons';
5-
import { useState } from 'react';
5+
import { useState, forwardRef } from 'react';
66

77
interface IProps extends InputProps {}
88

9-
export const InputPassword: FC<IProps> = (props) => {
9+
export const InputPassword = forwardRef<HTMLInputElement, IProps>((props, forwardedRef) => {
1010
const [isVisible, setIsVisible] = useState(false);
1111

1212
const toggleVisibility = () => setIsVisible(!isVisible);
1313

1414
return (
1515
<Input
16-
placeholder="••••••••"
1716
{...props}
17+
ref={forwardedRef}
1818
endContent={
1919
<button className="focus:outline-none" type="button" onClick={toggleVisibility}>
2020
{isVisible ? (
@@ -24,7 +24,10 @@ export const InputPassword: FC<IProps> = (props) => {
2424
)}
2525
</button>
2626
}
27+
placeholder="••••••••"
2728
type={isVisible ? 'text' : 'password'}
2829
/>
2930
);
30-
};
31+
});
32+
33+
InputPassword.displayName = 'InputPassword';

features/auth/actions/baseAuth.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ export type AuthActionFn = typeof login | typeof signup | typeof googleLogin | t
1717
*/
1818
const baseAuthProcedure = (action: EAuthServiceViaEmailAction) => {
1919
return baseProcedure
20-
.input(EmailLoginSchema, { type: 'formData' })
20+
.input(EmailLoginSchema)
2121
.output(z.void())
2222
.onSuccess(() => revalidatePath('/', 'layout'))
23+
.onError((error) => console.error(error))
2324
.handler(async ({ ctx, input }) => {
2425
const { error } = await ctx.supabase.auth[action](input);
2526

features/auth/actions/baseOAuth.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { baseProcedure } from '@/lib/zsa/baseProcedure';
1717
*/
1818
const baseOAuthProcedure = ({ options, ...credentials }: SignInWithOAuthCredentials) => {
1919
return baseProcedure
20-
.input(z.any(), { type: 'formData' })
20+
.input(z.void())
2121
.output(z.void())
2222
.onSuccess(() => revalidatePath('/', 'layout'))
2323
.handler(async ({ ctx }): Promise<void> => {

features/auth/actions/signOut.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { revalidatePath } from 'next/cache';
44
import { redirect } from 'next/navigation';
5-
import { z } from 'zod';
65

76
import { authedProcedure } from '@/lib/zsa/authedProcedure';
87

@@ -11,10 +10,10 @@ import { authedProcedure } from '@/lib/zsa/authedProcedure';
1110
* @tag server-action
1211
*/
1312
export const signOut = authedProcedure
14-
.input(z.any(), { type: 'formData' })
15-
.onSuccess(() => revalidatePath('/', 'layout'))
13+
.onSuccess(() => {
14+
revalidatePath('/', 'layout');
15+
redirect('/login');
16+
})
1617
.handler(async ({ ctx }) => {
1718
await ctx.supabase.auth.signOut();
18-
19-
redirect('/login');
2019
});
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { z } from 'zod';
22

33
export const EmailLoginSchema = z.object({
4-
email: z.string().email(),
5-
password: z.string().min(6),
4+
email: z.string().trim().email(),
5+
password: z.string().trim().min(6),
66
});

features/auth/actions/verifyOTP.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ import { baseProcedure } from '@/lib/zsa/baseProcedure';
88
* @tag server-action
99
* @see https://supabase.com/docs/reference/javascript/auth-verifyotp
1010
*/
11-
export const verifyOTP = baseProcedure.input(VerifyOTPSchema).handler(async ({ ctx, input }) => {
12-
return ctx.supabase.auth.verifyOtp(input);
13-
});
11+
export const verifyOTP = baseProcedure
12+
.input(VerifyOTPSchema)
13+
.handler(async ({ ctx, input }) => ctx.supabase.auth.verifyOtp(input));
+4-9
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { Button } from '@nextui-org/button';
2-
import { LogIn, LogOut } from 'lucide-react';
2+
import { LogIn } from 'lucide-react';
33
import Link from 'next/link';
44

5-
import { Submit } from '@/components/ui/form/Submit';
65
import { getProfile } from '@/features/auth/actions/getProfile';
7-
import { signOut } from '@/features/auth/actions/signOut';
6+
import { LogoutButton } from '@/features/auth/components/LogoutButton';
87
import { UserAvatar } from '@/features/user/components/UserAvatar';
98

109
export const AuthButton = async () => {
@@ -16,16 +15,12 @@ export const AuthButton = async () => {
1615
<UserAvatar src={user.profile?.avatar} />
1716
<strong>{user.profile?.username || user.email}</strong>
1817
</Link>
19-
<form>
20-
<Submit isIconOnly formAction={signOut} size="md" title="Logout" variant="light">
21-
<LogOut size="1.8cap" />
22-
</Submit>
23-
</form>
18+
<LogoutButton />
2419
</div>
2520
) : (
2621
<Button as={Link} href="/login" size="md" variant="bordered">
2722
<LogIn size="1.8cap" />
28-
Sign In
23+
Login
2924
</Button>
3025
);
3126
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use client';
2+
3+
import { Button } from '@nextui-org/button';
4+
import { useTransition } from 'react';
5+
import { toast } from 'sonner';
6+
7+
import { GitHubIcon } from '@/components/icons/GithubIcon';
8+
import { GoogleIcon } from '@/components/icons/GoogleIcon';
9+
import { DividerText } from '@/components/ui/DividerText';
10+
import { login, signup } from '@/features/auth/actions/baseAuth';
11+
import { googleLogin, githubLogin } from '@/features/auth/actions/baseOAuth';
12+
import { AuthLoginForm, type IAuthLoginFormProps } from '@/features/auth/components/AuthLoginForm';
13+
14+
interface IProps extends IAuthLoginFormProps {}
15+
16+
type LoginHandler = IAuthLoginFormProps['onSubmit'];
17+
type AuthActionFn = typeof googleLogin | typeof githubLogin;
18+
19+
export const AuthController: FC<IProps> = (props) => {
20+
const [, startTransition] = useTransition();
21+
22+
// With transition wrapper allows tracking the transition pending state
23+
const withTransition = (fn: AuthActionFn) => () => {
24+
startTransition(async () => {
25+
try {
26+
await fn(); // next.js redirects from sa -> returns undefined, so we use empty array as fallback
27+
28+
toast.success('Processing...');
29+
} catch (e) {
30+
toast.error(e.message);
31+
}
32+
});
33+
};
34+
35+
const handleLogin: LoginHandler = async (action, credentials) => {
36+
try {
37+
const [, err] = await { login, signup }[action](credentials);
38+
39+
if (err) {
40+
throw new Error(err.message); // coz: fn is safe async function
41+
}
42+
toast.success('Login successful');
43+
} catch (e) {
44+
toast.error(e.message);
45+
}
46+
};
47+
48+
return (
49+
<AuthLoginForm {...props} onSubmit={handleLogin}>
50+
{/* OAUTH */}
51+
<DividerText text="or" />
52+
53+
<Button onClick={withTransition(googleLogin)}>
54+
<GoogleIcon /> Google
55+
</Button>
56+
57+
<Button onClick={withTransition(githubLogin)}>
58+
<GitHubIcon /> GitHub
59+
</Button>
60+
</AuthLoginForm>
61+
);
62+
};

features/auth/components/AuthLoginForm.tsx

+58-39
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,93 @@
11
'use client';
22

3+
import { zodResolver } from '@hookform/resolvers/zod';
34
import { Button } from '@nextui-org/button';
45
import { Input } from '@nextui-org/input';
56
import { Spinner } from '@nextui-org/spinner';
67
import { LogIn, UserPlus, Mail, Lock } from 'lucide-react';
7-
import { useTransition, useRef } from 'react';
8+
import { useRef, useState, useTransition } from 'react';
9+
import { useForm, Controller } from 'react-hook-form';
810
import { toast } from 'sonner';
11+
import { z } from 'zod';
912

1013
import { Logo } from '@/components/icons';
11-
import { GitHubIcon } from '@/components/icons/GithubIcon';
12-
import { GoogleIcon } from '@/components/icons/GoogleIcon';
1314
import { formVariants } from '@/components/primitives';
14-
import { DividerText } from '@/components/ui/DividerText';
1515
import { Form } from '@/components/ui/form/Form';
1616
import { InputPassword } from '@/components/ui/form/InputPassword';
17-
import { login, signup, type AuthActionFn } from '@/features/auth/actions/baseAuth';
18-
import { githubLogin, googleLogin } from '@/features/auth/actions/baseOAuth';
17+
import { EmailLoginSchema } from '@/features/auth/actions/validators/emailLoginSchema';
18+
import { AuthTabs, type AuthTabKey } from '@/features/auth/components/AuthTabs';
19+
import { upperFirst } from '@/lib/utils/upperFirst';
20+
import { humanizeError } from '@/lib/zod/humanizeError';
1921

20-
interface IProps {
22+
export interface IAuthLoginFormProps {
2123
message?: string;
24+
onSubmit?: (action: AuthTabKey, credentials: z.infer<typeof EmailLoginSchema>) => Promise<void>;
25+
children?: React.ReactNode;
2226
}
2327

24-
export const AuthLoginForm: RC<IProps> = ({ message }) => {
28+
export const AuthLoginForm: RC<IAuthLoginFormProps> = ({ message, onSubmit, children }) => {
29+
const [activeTab, setActiveTab] = useState<AuthTabKey>('login');
30+
31+
const { control, ...form } = useForm({
32+
resolver: zodResolver(EmailLoginSchema),
33+
mode: 'all',
34+
defaultValues: {
35+
email: '',
36+
password: '',
37+
},
38+
});
39+
2540
const formRef = useRef<HTMLFormElement | null>(null);
26-
const [isSubmitting, startTransition] = useTransition();
2741

2842
const icoProps = {
2943
className: 'opacity-45',
3044
size: 19,
3145
};
3246

33-
// With transition wrapper allows tracking the transition pending state
34-
const withTransition = (fn: AuthActionFn) => () => {
35-
startTransition(async () => {
36-
try {
37-
const [, err] = (await fn(new FormData(formRef.current!))) ?? []; // next.js redirects from sa -> returns undefined, so we use empty array as fallback
38-
39-
if (err) {
40-
throw new Error(err.message); // coz: fn is safe async function
41-
}
42-
toast.success('Login successful');
43-
} catch (e) {
44-
toast.error(e.message);
45-
}
46-
});
47-
};
47+
const [isSubmitting, startTransition] = useTransition();
48+
49+
const handleSubmit = form.handleSubmit(
50+
async (credentials) => {
51+
startTransition(async () => {
52+
await onSubmit?.(activeTab, credentials);
53+
});
54+
},
55+
(errors) => {
56+
toast.error(humanizeError(errors));
57+
},
58+
);
4859

4960
return (
5061
<div className="container relative flex w-full flex-col justify-center gap-4 px-8 sm:max-w-sm">
5162
<h1 className="text-center text-4xl">
5263
<Logo className="m-auto size-20" />
5364
<span className="sr-only">Login</span>
5465
</h1>
66+
5567
{isSubmitting && <Spinner className="absolute inset-0 m-auto" size="lg" title="Logging in..." />}
56-
<Form ref={formRef} className={formVariants({ isSubmitting })} size="lg" variant="bordered">
57-
<Input name="email" placeholder="you@example.com" startContent={<Mail {...icoProps} />} />
58-
<InputPassword name="password" placeholder="••••••••" startContent={<Lock {...icoProps} />} />
59-
<Button color="primary" variant="ghost" onClick={withTransition(login)}>
60-
<LogIn /> Sign In
61-
</Button>
62-
<Button onClick={withTransition(signup)}>
63-
<UserPlus /> Sign Up
64-
</Button>
65-
<DividerText text="or" />
66-
<Button onClick={withTransition(googleLogin)}>
67-
<GoogleIcon /> Google
68-
</Button>
69-
<Button onClick={withTransition(githubLogin)}>
70-
<GitHubIcon /> GitHub
68+
69+
<AuthTabs active={activeTab} onTabChange={setActiveTab} />
70+
71+
<Form ref={formRef} className={formVariants({})} size="lg" variant="bordered" onSubmit={handleSubmit}>
72+
<Controller
73+
control={control}
74+
name="email"
75+
render={({ field }) => (
76+
<Input {...field} name="email" placeholder="you@example.com" startContent={<Mail {...icoProps} />} />
77+
)}
78+
/>
79+
<Controller
80+
control={control}
81+
name="password"
82+
render={({ field }) => <InputPassword {...field} startContent={<Lock {...icoProps} />} />}
83+
/>
84+
85+
<Button color="primary" type="submit" variant="ghost">
86+
{activeTab === 'login' ? <LogIn /> : <UserPlus />}
87+
{upperFirst(activeTab)}
7188
</Button>
89+
90+
{children}
7291
</Form>
7392

7493
{message && <p className="mt-4 bg-foreground/10 p-4 text-center text-foreground">{message}</p>}

features/auth/components/AuthTabs.tsx

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Tabs, Tab } from '@nextui-org/tabs';
2+
3+
import { upperFirst } from '@/lib/utils/upperFirst';
4+
5+
const TABS = ['login', 'signup'] as const;
6+
7+
export type AuthTabKey = (typeof TABS)[number];
8+
9+
interface IProps {
10+
active: AuthTabKey;
11+
onTabChange: (key: AuthTabKey) => void;
12+
}
13+
14+
export const AuthTabs: RC<IProps> = ({ onTabChange, active }) => (
15+
<Tabs
16+
aria-label="Options"
17+
className="w-full"
18+
classNames={{ base: 'flex justify-center w-full' }}
19+
selectedKey={active}
20+
variant="light"
21+
onSelectionChange={(key) => onTabChange(key.toString() as AuthTabKey)}
22+
>
23+
{TABS.map((key) => (
24+
<Tab key={key} title={upperFirst(key)} />
25+
))}
26+
</Tabs>
27+
);
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use client';
2+
3+
import { Button } from '@nextui-org/button';
4+
import { LogOut } from 'lucide-react';
5+
import { useServerAction } from 'zsa-react';
6+
7+
import { signOut } from '@/features/auth/actions/signOut';
8+
9+
export const LogoutButton = () => {
10+
const { isPending, execute: logout } = useServerAction(signOut);
11+
12+
return (
13+
<Button isIconOnly isLoading={isPending} size="md" title="Logout" variant="light" onClick={() => logout()}>
14+
<LogOut size="1.8cap" />
15+
</Button>
16+
);
17+
};

lib/supabase/middleware.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ export async function updateSession(request: NextRequest) {
3636
data: { user },
3737
} = await supabase.auth.getUser();
3838

39-
if (!user && !request.nextUrl.pathname.startsWith('/login') && !request.nextUrl.pathname.startsWith('/auth')) {
39+
const excludedPaths = ['/login', '/api/auth'];
40+
41+
if (!user && !excludedPaths.some((path) => request.nextUrl.pathname.startsWith(path))) {
4042
// no user, potentially respond by redirecting the user to the login page
4143
const url = request.nextUrl.clone();
4244

0 commit comments

Comments
 (0)