Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 22 additions & 16 deletions app/(auth)/accept-invite/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient, useSession } from "@/lib/auth/client";
import { toast } from "sonner";
import { OtpVerificationForm } from "@/components/auth/otp-verification-form";

export default function AcceptInvitePage() {
const router = useRouter();
Expand All @@ -17,7 +18,7 @@ export default function AcceptInvitePage() {
const [isLoading, setIsLoading] = useState(false);
const [isAccepting, setIsAccepting] = useState(false);
const [sent, setSent] = useState(false);
const { data: session, isPending } = useSession();
const { data: session, isPending, refetch } = useSession();

async function handleAcceptInvitation() {
setIsAccepting(true);
Expand All @@ -33,40 +34,42 @@ export default function AcceptInvitePage() {
router.push("/start");
}
} catch (error) {
console.error("Accept invitation error:", error);
toast.error("Failed to accept invitation");
} finally {
setIsAccepting(false);
}
}

async function handleSignIn() {
async function handleSendOtp() {
if (!email) {
toast.error("Please enter your email");
return;
}

setIsLoading(true);
try {
const result = await authClient.signIn.magicLink({
const result = await authClient.emailOtp.sendVerificationOtp({
email,
callbackURL: `/accept-invite?email=${encodeURIComponent(email)}${invitationId ? `&invitationId=${invitationId}` : ""}`,
type: "sign-in",
});

if (result.error) {
toast.error(result.error.message || "Failed to send sign in link");
toast.error(result.error.message || "Failed to send verification code");
} else {
setSent(true);
toast.success("Check your email for the sign in link");
toast.success("Check your email for the verification code");
}
} catch (error) {
console.error("Sign in error:", error);
toast.error("Failed to send sign in link");
toast.error("Failed to send verification code");
} finally {
setIsLoading(false);
}
}

async function handleOtpSuccess() {
await refetch();
}

if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
Expand Down Expand Up @@ -105,14 +108,17 @@ export default function AcceptInvitePage() {
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-6 p-8">
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold">Check your email</h1>
<h1 className="text-2xl font-bold">Enter verification code</h1>
<p className="text-muted-foreground">
We sent a sign in link to <strong>{email}</strong>
</p>
<p className="text-sm text-muted-foreground">
Click the link in the email to sign in and join the workspace.
We sent a 6-digit code to <strong>{email}</strong>
</p>
</div>

<OtpVerificationForm
email={email}
onSuccess={handleOtpSuccess}
onBack={() => setSent(false)}
/>
</div>
</div>
);
Expand Down Expand Up @@ -141,11 +147,11 @@ export default function AcceptInvitePage() {
</div>

<Button
onClick={handleSignIn}
onClick={handleSendOtp}
disabled={isLoading || !email}
className="w-full"
>
{isLoading ? "Sending..." : "Sign in to accept invitation"}
{isLoading ? "Sending..." : "Send verification code"}
</Button>
</div>
</div>
Expand Down
52 changes: 25 additions & 27 deletions app/(auth)/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { useSearchParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand All @@ -15,29 +15,35 @@ import {
import { authClient } from "@/lib/auth/client";
import { toast } from "sonner";
import { Loader2, Mail } from "lucide-react";
import { OtpVerificationForm } from "@/components/auth/otp-verification-form";

export default function SignInPage() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || "/start";

const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);

async function handleSubmit(e: React.FormEvent) {
async function handleSendOtp(e: React.FormEvent) {
e.preventDefault();
setLoading(true);

try {
await authClient.signIn.magicLink({
const result = await authClient.emailOtp.sendVerificationOtp({
email,
callbackURL: redirect,
type: "sign-in",
});
setSent(true);
toast.success("Check your email for the sign-in link");

if (result.error) {
toast.error(result.error.message || "Failed to send verification code");
} else {
setSent(true);
toast.success("Check your email for the verification code");
}
} catch (error) {
toast.error("Failed to send sign-in link. Please try again.");
console.error(error);
toast.error("Failed to send verification code. Please try again.");
} finally {
setLoading(false);
}
Expand All @@ -51,25 +57,17 @@ export default function SignInPage() {
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<Mail className="h-6 w-6 text-primary" />
</div>
<CardTitle>Check your email</CardTitle>
<CardTitle>Enter verification code</CardTitle>
<CardDescription>
We sent a sign-in link to <strong>{email}</strong>
We sent a 6-digit code to <strong>{email}</strong>
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<p className="text-sm text-muted-foreground mb-4">
Click the link in the email to sign in. The link will expire in 15
minutes.
</p>
<Button
variant="outline"
onClick={() => {
setSent(false);
setEmail("");
}}
>
Use a different email
</Button>
<CardContent>
<OtpVerificationForm
email={email}
onSuccess={() => router.push(redirect)}
onBack={() => setSent(false)}
/>
</CardContent>
</Card>
</div>
Expand All @@ -82,11 +80,11 @@ export default function SignInPage() {
<CardHeader className="text-center">
<CardTitle>Sign in to Manage</CardTitle>
<CardDescription>
Enter your email to receive a sign-in link
Enter your email to receive a verification code
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSendOtp} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
Expand All @@ -101,7 +99,7 @@ export default function SignInPage() {
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Send sign-in link
Send verification code
</Button>
</form>
</CardContent>
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

92 changes: 92 additions & 0 deletions components/auth/otp-verification-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/client";
import { toast } from "sonner";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";

interface OtpVerificationFormProps {
email: string;
onSuccess: () => void;
onBack: () => void;
}

export function OtpVerificationForm({
email,
onSuccess,
onBack,
}: OtpVerificationFormProps) {
const [otp, setOtp] = useState("");
const [isLoading, setIsLoading] = useState(false);

async function handleVerifyOtp(e: React.FormEvent) {
e.preventDefault();
if (otp.length !== 6) return;

setIsLoading(true);
try {
const result = await authClient.signIn.emailOtp({
email,
otp,
});

if (result.error) {
toast.error(result.error.message || "Invalid verification code");
} else {
toast.success("Signed in successfully");
onSuccess();
}
} catch {
toast.error("Failed to verify code. Please try again.");
} finally {
setIsLoading(false);
}
}

function handleBack() {
setOtp("");
onBack();
}

return (
<form onSubmit={handleVerifyOtp} className="space-y-4">
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={otp}
onChange={setOtp}
disabled={isLoading}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || otp.length !== 6}
>
{isLoading ? "Verifying..." : "Verify code"}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleBack}
>
Use a different email
</Button>
</form>
);
}
71 changes: 71 additions & 0 deletions components/ui/input-otp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client"

import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Minus } from "lucide-react"

import { cn } from "@/lib/utils"

const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"

const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"

const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]

return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"

const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
Loading