Skip to content

Commit 5f231d6

Browse files
committed
Implement login functionality and enhance authentication flow: Add Login and OTP components for user authentication, integrate better-auth for session management, and update routing with authentication middleware. Refactor drizzle configuration and update dependencies in package.json and pnpm-lock.yaml for improved compatibility.
1 parent ebf0ed6 commit 5f231d6

File tree

19 files changed

+646
-79
lines changed

19 files changed

+646
-79
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../..

apps/api/drizzle.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { defineConfig } from 'drizzle-kit';
44

55
export default defineConfig({
66
dialect: 'postgresql',
7-
schema: './src/databases/database.schema.ts',
7+
schema: './src/databases/drizzle.schema.ts',
88
out: './src/databases/migrations',
99
dbCredentials: {
1010
url: process.env['DATABASE_URL']!,

apps/api/src/auth/better-auth.provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ export const BetterAuthProvider = {
2626
authConfig: AuthConfig,
2727
) => {
2828
return betterAuth({
29-
experimental: { joins: true },
3029
database: drizzleAdapter(txHost.tx, {
3130
provider: 'pg',
3231
schema: {
@@ -38,6 +37,7 @@ export const BetterAuthProvider = {
3837
},
3938
}),
4039
advanced: {
40+
cookiePrefix: appConfig.name,
4141
database: {
4242
generateId: false,
4343
},

apps/web/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@fontsource-variable/jetbrains-mono": "^5.2.8",
1717
"@tailwindcss/vite": "^4.0.6",
1818
"@tanstack/react-devtools": "^0.7.0",
19+
"@tanstack/react-form": "^1.27.7",
1920
"@tanstack/react-query": "^5.90.16",
2021
"@tanstack/react-router": "^1.132.0",
2122
"@tanstack/react-router-devtools": "^1.132.0",
@@ -26,6 +27,7 @@
2627
"better-auth": "^1.4.10",
2728
"class-variance-authority": "^0.7.1",
2829
"clsx": "^2.1.1",
30+
"input-otp": "^1.4.2",
2931
"lucide-react": "^0.562.0",
3032
"nitro": "latest",
3133
"openapi-fetch": "^0.15.0",
@@ -35,7 +37,8 @@
3537
"tailwind-merge": "^3.4.0",
3638
"tailwindcss": "^4.0.6",
3739
"tw-animate-css": "^1.4.0",
38-
"vite-tsconfig-paths": "^5.1.4"
40+
"vite-tsconfig-paths": "^5.1.4",
41+
"zod": "^4.3.4"
3942
},
4043
"devDependencies": {
4144
"@tanstack/devtools-vite": "^0.3.11",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { cn } from '@/lib/utils'
2+
3+
interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {
4+
children: React.ReactNode
5+
}
6+
7+
export function Container({ children, className, ...props }: ContainerProps) {
8+
return (
9+
<div {...props} className={cn('container mx-auto px-4 md:px-6', className)}>
10+
{children}
11+
</div>
12+
)
13+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { cn } from '@/lib/utils'
2+
import { Button } from '@/components/ui/button'
3+
import {
4+
Card,
5+
CardContent,
6+
CardDescription,
7+
CardHeader,
8+
CardTitle,
9+
} from '@/components/ui/card'
10+
import {
11+
Field,
12+
FieldError,
13+
FieldGroup,
14+
FieldLabel,
15+
} from '@/components/ui/field'
16+
import { Input } from '@/components/ui/input'
17+
import { useMutation } from '@tanstack/react-query'
18+
import { useForm } from '@tanstack/react-form'
19+
import { z } from 'zod'
20+
import { api } from '@/lib/api'
21+
22+
const emailSchema = z.object({
23+
email: z.email('Please enter a valid email address'),
24+
})
25+
26+
interface LoginFormProps extends React.ComponentProps<'div'> {
27+
onOtpSent: (email: string) => void
28+
}
29+
30+
export function LoginForm({ className, onOtpSent, ...props }: LoginFormProps) {
31+
const sendOtpMutation = useMutation({
32+
mutationFn: async (email: string) => api.auth.sendSignInOtp(email),
33+
onSuccess: (_data, email) => {
34+
onOtpSent(email)
35+
},
36+
})
37+
38+
const form = useForm({
39+
defaultValues: { email: '' },
40+
validators: {
41+
onBlur: emailSchema,
42+
},
43+
onSubmit: async ({ value }) => sendOtpMutation.mutateAsync(value.email),
44+
})
45+
46+
return (
47+
<div className={cn('flex flex-col gap-6', className)} {...props}>
48+
<Card>
49+
<CardHeader>
50+
<CardTitle>Login to your account</CardTitle>
51+
<CardDescription>
52+
Enter your email below to login to your account
53+
</CardDescription>
54+
</CardHeader>
55+
<CardContent>
56+
<FieldGroup>
57+
<form.Field name="email">
58+
{(field) => (
59+
<Field data-invalid={field.state.meta.errors.length > 0}>
60+
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
61+
<Input
62+
id={field.name}
63+
type="email"
64+
value={field.state.value}
65+
onChange={(e) => field.handleChange(e.target.value)}
66+
onBlur={field.handleBlur}
67+
autoComplete="email"
68+
/>
69+
<FieldError />
70+
</Field>
71+
)}
72+
</form.Field>
73+
<Button
74+
onClick={() => form.handleSubmit()}
75+
disabled={sendOtpMutation.isPending || !form.state.canSubmit}
76+
>
77+
{sendOtpMutation.isPending ? 'Sending...' : 'Sign in'}
78+
</Button>
79+
</FieldGroup>
80+
</CardContent>
81+
</Card>
82+
</div>
83+
)
84+
}

apps/web/src/components/login.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useState } from 'react'
2+
import { LoginForm } from './login-form'
3+
import { Container } from './container'
4+
import { OTPForm } from './otp-form'
5+
6+
export function Login() {
7+
const [email, setEmail] = useState('')
8+
9+
return (
10+
<Container>
11+
{email ? <OTPForm email={email} /> : <LoginForm onOtpSent={setEmail} />}
12+
</Container>
13+
)
14+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Button } from '@/components/ui/button'
2+
import {
3+
Card,
4+
CardContent,
5+
CardDescription,
6+
CardHeader,
7+
CardTitle,
8+
} from '@/components/ui/card'
9+
import {
10+
Field,
11+
FieldDescription,
12+
FieldError,
13+
FieldGroup,
14+
FieldLabel,
15+
} from '@/components/ui/field'
16+
import {
17+
InputOTP,
18+
InputOTPGroup,
19+
InputOTPSlot,
20+
} from '@/components/ui/input-otp'
21+
import { api } from '@/lib/api'
22+
import { useMutation, useQueryClient } from '@tanstack/react-query'
23+
import { useForm } from '@tanstack/react-form'
24+
import { z } from 'zod'
25+
import { useNavigate, useRouter } from '@tanstack/react-router'
26+
import { router } from 'better-auth/api'
27+
28+
const otpSchema = z.string().length(6, 'Please enter the 6-digit code')
29+
30+
interface OTPFormProps extends React.ComponentProps<typeof Card> {
31+
email: string
32+
}
33+
34+
export function OTPForm({ email, ...props }: OTPFormProps) {
35+
const navigate = useNavigate()
36+
const queryClient = useQueryClient()
37+
const router = useRouter()
38+
39+
const verifyOtpMutation = useMutation({
40+
mutationFn: async (otp: string) => api.auth.signInWithOtp(email, otp),
41+
onSuccess: async () => {
42+
await queryClient.invalidateQueries()
43+
router.invalidate()
44+
navigate({ to: '/' })
45+
},
46+
})
47+
48+
const form = useForm({
49+
defaultValues: { otp: '' },
50+
onSubmit: async ({ value }) => verifyOtpMutation.mutateAsync(value.otp),
51+
})
52+
53+
return (
54+
<Card {...props}>
55+
<CardHeader>
56+
<CardTitle>Enter verification code</CardTitle>
57+
<CardDescription>We sent a 6-digit code to {email}.</CardDescription>
58+
</CardHeader>
59+
<CardContent>
60+
<FieldGroup>
61+
<form.Field
62+
name="otp"
63+
validators={{
64+
onBlur: ({ value }) => {
65+
const result = otpSchema.safeParse(value)
66+
return result.success
67+
? undefined
68+
: result.error.issues[0]?.message
69+
},
70+
}}
71+
>
72+
{(field) => (
73+
<Field data-invalid={field.state.meta.errors.length > 0}>
74+
<FieldLabel htmlFor={field.name}>Verification code</FieldLabel>
75+
<InputOTP
76+
maxLength={6}
77+
id={field.name}
78+
value={field.state.value}
79+
onChange={(value) => field.handleChange(value)}
80+
onBlur={field.handleBlur}
81+
>
82+
<InputOTPGroup className="gap-2.5 *:data-[slot=input-otp-slot]:rounded-md *:data-[slot=input-otp-slot]:border">
83+
<InputOTPSlot index={0} />
84+
<InputOTPSlot index={1} />
85+
<InputOTPSlot index={2} />
86+
<InputOTPSlot index={3} />
87+
<InputOTPSlot index={4} />
88+
<InputOTPSlot index={5} />
89+
</InputOTPGroup>
90+
</InputOTP>
91+
<FieldError
92+
errors={field.state.meta.errors.map((e) => ({
93+
message: typeof e === 'string' ? e : undefined,
94+
}))}
95+
/>
96+
<FieldDescription>
97+
Enter the 6-digit code sent to your email.
98+
</FieldDescription>
99+
</Field>
100+
)}
101+
</form.Field>
102+
<FieldGroup>
103+
<Button
104+
onClick={() => form.handleSubmit()}
105+
disabled={verifyOtpMutation.isPending || !form.state.canSubmit}
106+
>
107+
{verifyOtpMutation.isPending ? 'Verifying...' : 'Verify'}
108+
</Button>
109+
<FieldDescription className="text-center">
110+
Didn&apos;t receive the code? <a href="#">Resend</a>
111+
</FieldDescription>
112+
</FieldGroup>
113+
</FieldGroup>
114+
</CardContent>
115+
</Card>
116+
)
117+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import * as React from "react"
2+
import { OTPInput, OTPInputContext } from "input-otp"
3+
4+
import { cn } from "@/lib/utils"
5+
import { MinusIcon } from "lucide-react"
6+
7+
function InputOTP({
8+
className,
9+
containerClassName,
10+
...props
11+
}: React.ComponentProps<typeof OTPInput> & {
12+
containerClassName?: string
13+
}) {
14+
return (
15+
<OTPInput
16+
data-slot="input-otp"
17+
containerClassName={cn(
18+
"cn-input-otp flex items-center has-disabled:opacity-50",
19+
containerClassName
20+
)}
21+
spellCheck={false}
22+
className={cn(
23+
"disabled:cursor-not-allowed",
24+
className
25+
)}
26+
{...props}
27+
/>
28+
)
29+
}
30+
31+
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
32+
return (
33+
<div
34+
data-slot="input-otp-group"
35+
className={cn("has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive rounded-none has-aria-invalid:ring-1 flex items-center", className)}
36+
{...props}
37+
/>
38+
)
39+
}
40+
41+
function InputOTPSlot({
42+
index,
43+
className,
44+
...props
45+
}: React.ComponentProps<"div"> & {
46+
index: number
47+
}) {
48+
const inputOTPContext = React.useContext(OTPInputContext)
49+
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
50+
51+
return (
52+
<div
53+
data-slot="input-otp-slot"
54+
data-active={isActive}
55+
className={cn(
56+
"dark:bg-input/30 border-input data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive size-8 border-y border-r text-xs transition-all outline-none first:rounded-none first:border-l last:rounded-none data-[active=true]:ring-1 relative flex items-center justify-center data-[active=true]:z-10",
57+
className
58+
)}
59+
{...props}
60+
>
61+
{char}
62+
{hasFakeCaret && (
63+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
64+
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000 bg-foreground h-4 w-px" />
65+
</div>
66+
)}
67+
</div>
68+
)
69+
}
70+
71+
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
72+
return (
73+
<div
74+
data-slot="input-otp-separator"
75+
className="[&_svg:not([class*='size-'])]:size-4 flex items-center"
76+
role="separator"
77+
{...props}
78+
>
79+
<MinusIcon
80+
/>
81+
</div>
82+
)
83+
}
84+
85+
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { cn } from "@/lib/utils"
2+
import { Loader2Icon } from "lucide-react"
3+
4+
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
5+
return (
6+
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
7+
)
8+
}
9+
10+
export { Spinner }

0 commit comments

Comments
 (0)