Skip to content

Commit

Permalink
feat(one-to-one): generally improve the UI and UX of auth forms (#5)
Browse files Browse the repository at this point in the history
* fix(one-to-one): keep auth forms disabled, validate state responses are what we expect

* fix(one-to-one): an undefined state is to be expected

* feat(one-to-one): use in-page form rather than a dialog for magic link auth

* fix(one-to-one): improve appearance of cards on mobile (by removing their border and shadow) for auth pages

* chore(release): changeset

* chore(one-to-one): prettier
  • Loading branch information
jakeisonline authored Jan 6, 2025
1 parent 66db1af commit 571ff26
Show file tree
Hide file tree
Showing 10 changed files with 109 additions and 101 deletions.
6 changes: 6 additions & 0 deletions .changeset/fresh-cycles-speak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"next-auth-template-one-to-one": minor
"next-auth-template": patch
---

feat(one-to-one): generally improve the UI and UX of auth forms
2 changes: 1 addition & 1 deletion templates/one-to-one/src/app/(auth)/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import Link from "next/link"

export default function SignInPage() {
return (
<Card className="mx-auto w-[24rem]">
<Card className="mx-auto w-[24rem] border-0 shadow-none md:border md:shadow-sm">
<CardHeader>
<CardTitle className="text-2xl">Sign in</CardTitle>
<CardDescription>Select an option below to sign in.</CardDescription>
Expand Down
4 changes: 2 additions & 2 deletions templates/one-to-one/src/app/(auth)/signout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { Loader2 } from "lucide-react"
export default function SignoutPage() {
return (
<>
<div className="text-center pt-10">
<div className="pt-10 text-center">
<span className="text-8xl">👋</span>
<p className="mt-6 flex items-center justify-center gap-1.5">
<Loader2 className="h-4 w-4 animate-spin inline-block" />
<Loader2 className="inline-block h-4 w-4 animate-spin" />
Signing you out...
</p>
</div>
Expand Down
2 changes: 1 addition & 1 deletion templates/one-to-one/src/app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import Link from "next/link"

export default function SignInPage() {
return (
<Card className="mx-auto w-[24rem]">
<Card className="mx-auto w-[24rem] border-0 shadow-none md:border md:shadow-sm">
<CardHeader>
<CardTitle className="text-2xl">Create an account</CardTitle>
<CardDescription>
Expand Down
4 changes: 2 additions & 2 deletions templates/one-to-one/src/app/(auth)/verify/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { Card, CardContent } from "@/components/ui/card"

export default function VerifyPage() {
return (
<Card className="mx-auto w-[24rem]">
<Card className="mx-auto w-[24rem] border-0 shadow-none">
<CardContent>
<div className="text-center pt-10">
<div className="pt-10 text-center">
<span className="text-8xl">🎉</span>
<p className="mt-6">
Check your inbox (and maybe your spam folder) for a link.
Expand Down
28 changes: 21 additions & 7 deletions templates/one-to-one/src/components/account-setup-form.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client"

import { doAccountSetup } from "@/actions/account/do-account-setup"

import {
Card,
CardContent,
Expand All @@ -17,16 +16,31 @@ import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react"
import { User } from "@/db/schema/users"
import { serverActionResponseSchema } from "@/lib/schemas"

export function AccountSetupForm({ currentUser }: { currentUser: User }) {
if (!currentUser) {
throw new Error("currentUser has not been passed to AccountSetupForm")
}

const [state, formAction, isPending] = useActionState(doAccountSetup, undefined)
const [state, formAction, isPending] = useActionState(
doAccountSetup,
undefined,
)
const [userName, setUserName] = useState(currentUser.name ?? "")
const router = useRouter()

// Validate the state response is what we're expecting
const validState = serverActionResponseSchema.safeParse(state)

// Check if the state is valid
if (state !== undefined && !validState.success) {
throw new Error("Invalid state response from the server")
}

// We want to keep the form disabled if the action is successful, because we're going to redirect the user and the form is not reusable.
const isDisabled = isPending || state?.status === "success"

useEffect(() => {
if (state?.status === "success") {
router.push("/app")
Expand All @@ -35,7 +49,7 @@ export function AccountSetupForm({ currentUser }: { currentUser: User }) {
}, [state])

return (
<Card className="mx-auto w-[24rem]">
<Card className="mx-auto w-[24rem] border-0 shadow-none md:border md:shadow-sm">
<CardHeader>
<CardTitle className="text-2xl">Before you get started</CardTitle>
<CardDescription>
Expand All @@ -52,18 +66,18 @@ export function AccountSetupForm({ currentUser }: { currentUser: User }) {
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
disabled={isPending}
disabled={isDisabled}
required
autoFocus
/>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
This is how you would like to be addressed.
</p>
</div>
</CardContent>
<CardFooter className="flex justify-end">
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Button type="submit" disabled={isDisabled}>
{isDisabled && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Complete sign up
</Button>
</CardFooter>
Expand Down
138 changes: 61 additions & 77 deletions templates/one-to-one/src/components/magic-sign-in-button.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,32 @@
"use client"

import {
Dialog,
DialogContent,
DialogTrigger,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Loader2, CircleX } from "lucide-react"
import { useActionState, useEffect } from "react"
import { Loader2, CircleX, Sparkles } from "lucide-react"
import { useActionState, useEffect, useState } from "react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ServerActionResponse } from "@/lib/types"
import { useRouter } from "next/navigation"
import { doMagicAuth } from "@/actions/auth/do-magic-auth"
import { serverActionResponseSchema } from "@/lib/schemas"

export function MagicSignInButton() {
return (
<>
<div className="w-full border-t border-gray-200 mt-6 text-center">
<p className="-translate-y-3 inline-block px-3 bg-card">or</p>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" className="w-full">
Send me a magic link
</Button>
</DialogTrigger>
<DialogContent>
<MagicSignInDialogForm />
</DialogContent>
</Dialog>
</>
)
}

function MagicSignInDialogForm() {
const [state, formAction, isPending] = useActionState(doMagicAuth, undefined)
const [email, setEmail] = useState("")
const router = useRouter()

// Validate the state response is what we're expecting
const validState = serverActionResponseSchema.safeParse(state)

// Check if the state is valid
if (state !== undefined && !validState.success) {
throw new Error("Invalid state response from the server")
}

// We want to keep the form disabled if the action is successful, because we're going to redirect the user and the form is not reusable.
const isDisabled = isPending || state?.status === "success"

// Redirect the user to the verify page if the action is successful
useEffect(() => {
if (state?.status === "success") {
router.push("/verify")
Expand All @@ -52,52 +35,53 @@ function MagicSignInDialogForm() {
}, [state])

return (
<form action={formAction}>
<DialogHeader>
<DialogTitle>Sign in using a magic link</DialogTitle>
<DialogDescription>
Enter your email below and we&apos;ll send you a special (magic) link
you can use to sign in without a password.
</DialogDescription>
</DialogHeader>
<div className="w-full my-4">
<Label htmlFor="email" className="sr-only">
Email
</Label>
<Input id="email" name="email" className="w-full" disabled={isPending} />
<>
<div className="mt-6 w-full border-t border-gray-200 text-center">
<p className="bg-card inline-block -translate-y-3 px-3">or</p>
</div>
{state?.messages && <MagicSignInDialogError state={state} />}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={isPending}>
Cancel
<div className="w-full">
<form className="flex flex-col gap-3" action={formAction}>
<Label htmlFor="email" className="sr-only">
Email
</Label>
<Input
id="email"
name="email"
className="w-full"
placeholder="Enter your email"
disabled={isDisabled}
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{state?.messages && (
<>
{state.messages.map((message, index) => (
<Alert key={index} className="my-4 bg-red-100">
<CircleX className="h-4 w-4 stroke-red-700" />
<AlertTitle>{message.title}</AlertTitle>
<AlertDescription>{message.body}</AlertDescription>
</Alert>
))}
</>
)}
<Button
variant="outline"
type="submit"
disabled={isDisabled}
className="w-full"
>
{isDisabled && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Send me a magic link
</Button>
</DialogClose>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Send magic link
</Button>
</DialogFooter>
</form>
)
}

function MagicSignInDialogError({
state,
}: {
state: ServerActionResponse | null
}) {
if (!state?.messages) return null

return (
<>
{state.messages.map((message, index) => (
<Alert key={index} className="my-4 bg-red-100">
<CircleX className="h-4 w-4 stroke-red-700" />
<AlertTitle>{message.title}</AlertTitle>
<AlertDescription>{message.body}</AlertDescription>
</Alert>
))}
<Alert className="bg-muted border-0">
<Sparkles className="stroke-muted-foreground h-4 w-4" />
<AlertDescription className="text-muted-foreground">
We&apos;ll email you a magic link for a password-free sign in.
</AlertDescription>
</Alert>
</form>
</div>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function SocialSignInButton({
return (
<form action={formAction}>
<input type="hidden" name="provider" value={providerName} />
<Button
<Button
type="submit"
className={cn("w-full", className)}
{...props}
Expand Down
10 changes: 10 additions & 0 deletions templates/one-to-one/src/lib/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from "zod"

export const serverActionResponseSchema = z.object({
status: z.enum(["success", "error"]),
messages: z.array(z.object({
code: z.string().optional(),
title: z.string(),
body: z.string(),
})).optional(),
})
14 changes: 4 additions & 10 deletions templates/one-to-one/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
export interface ServerActionResponse {
status: "success" | "error"
messages?: [
{
code?: string
title: string
body: string
},
]
}
import { z } from "zod"
import { serverActionResponseSchema } from "@/lib/schemas"

export type ServerActionResponse = z.infer<typeof serverActionResponseSchema>

0 comments on commit 571ff26

Please sign in to comment.