Skip to content

Commit cf83fea

Browse files
committed
feat: add Sonner Toaster and client-side login flow with MFA toast
- add Toaster component (components/ui/sonner.tsx) and render it in RootLayout - convert LoginForm to client-side submit using fetch; add isSubmitting state, timeout-backed MFA loading toast, dismiss logic, redirect handling, and improved error toasts - add sonner and next-themes to dependencies
1 parent 6bf5ee6 commit cf83fea

4 files changed

Lines changed: 126 additions & 2 deletions

File tree

radius-proxy/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from "next";
22
import { Geist, Geist_Mono } from "next/font/google";
3+
import { Toaster } from "@/components/ui/sonner";
34
import "./globals.css";
45

56
const geistSans = Geist({
@@ -28,6 +29,7 @@ export default function RootLayout({
2829
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
2930
>
3031
{children}
32+
<Toaster />
3133
</body>
3234
</html>
3335
);

radius-proxy/components/login-form.tsx

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
"use client"
2+
13
import { cn } from "@/lib/utils"
24
import { Button } from "@/components/ui/button"
35
import { Card, CardContent } from "@/components/ui/card"
46
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
57
import { Input } from "@/components/ui/input"
8+
import { toast } from "sonner"
69
import Image from "next/image"
10+
import { useState, useRef } from "react"
711

812
interface LoginFormProps extends React.ComponentProps<'div'> {
913
clientId: string
@@ -16,12 +20,85 @@ interface LoginFormProps extends React.ComponentProps<'div'> {
1620
export function LoginForm({ className, clientId, redirectUri, state, error, errorDescription, ...props }: LoginFormProps) {
1721
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
1822
const formAction = `${basePath}/api/oauth/authorize`;
23+
const [isSubmitting, setIsSubmitting] = useState(false);
24+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
25+
const toastId = useRef<string | number | null>(null);
26+
27+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
28+
e.preventDefault();
29+
30+
if (isSubmitting) return;
31+
32+
setIsSubmitting(true);
33+
timeoutRef.current = setTimeout(() => {
34+
toastId.current = toast.loading("Check your phone for an MFA prompt!", {
35+
duration: Infinity,
36+
});
37+
}, 2000);
38+
39+
try {
40+
const formData = new FormData(e.currentTarget);
41+
42+
const response = await fetch(formAction, {
43+
method: 'POST',
44+
body: formData
45+
});
46+
47+
if (timeoutRef.current) {
48+
clearTimeout(timeoutRef.current);
49+
timeoutRef.current = null;
50+
}
51+
52+
// Dismiss the MFA toast if it was shown
53+
if (toastId.current) {
54+
toast.dismiss(toastId.current);
55+
toastId.current = null;
56+
}
57+
58+
if (response.redirected) {
59+
// Follow the redirect
60+
window.location.href = response.url;
61+
} else if (!response.ok) {
62+
// Handle error response
63+
const responseText = await response.text();
64+
const errorMatch = responseText.match(/error=([^&]+)/);
65+
const descMatch = responseText.match(/error_description=([^&]+)/);
66+
67+
if (errorMatch) {
68+
toast.error("Authentication failed", {
69+
description: descMatch ? decodeURIComponent(descMatch[1]) : "Please check your credentials"
70+
});
71+
} else {
72+
toast.error("Authentication failed", {
73+
description: "Please check your credentials and try again"
74+
});
75+
}
76+
}
77+
} catch (err) {
78+
// Clear timeout on error
79+
if (timeoutRef.current) {
80+
clearTimeout(timeoutRef.current);
81+
timeoutRef.current = null;
82+
}
83+
84+
if (toastId.current) {
85+
toast.dismiss(toastId.current);
86+
toastId.current = null;
87+
}
88+
89+
toast.error("Connection failed", {
90+
description: "Unable to connect to the authentication server"
91+
});
92+
} finally {
93+
setIsSubmitting(false);
94+
}
95+
};
1996

2097
return (
2198
<div className={cn("flex flex-col gap-6", className)} {...props}>
2299
<Card className="overflow-hidden p-0">
23100
<CardContent className="grid p-0 md:grid-cols-2">
24-
<form className="p-6 md:p-8" method="post" action={formAction}>
101+
<form className="p-6 md:p-8" onSubmit={handleSubmit}>
25102
<FieldGroup>
26103
<div className="flex flex-col items-center gap-2 text-center">
27104
<h1 className="text-2xl font-bold">Welcome back</h1>
@@ -53,7 +130,9 @@ export function LoginForm({ className, clientId, redirectUri, state, error, erro
53130
{redirectUri && (<input type="hidden" name="redirect_uri" value={redirectUri} />)}
54131
{state && (<input type="hidden" name="state" value={state} />)}
55132
<Field>
56-
<Button type="submit">Login</Button>
133+
<Button type="submit" disabled={isSubmitting}>
134+
{isSubmitting ? "Authenticating..." : "Login"}
135+
</Button>
57136
</Field>
58137
</FieldGroup>
59138
</form>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"use client"
2+
3+
import {
4+
CircleCheckIcon,
5+
InfoIcon,
6+
Loader2Icon,
7+
OctagonXIcon,
8+
TriangleAlertIcon,
9+
} from "lucide-react"
10+
import { useTheme } from "next-themes"
11+
import { Toaster as Sonner, ToasterProps } from "sonner"
12+
13+
const Toaster = ({ ...props }: ToasterProps) => {
14+
const { theme = "system" } = useTheme()
15+
16+
return (
17+
<Sonner
18+
theme={theme as ToasterProps["theme"]}
19+
className="toaster group"
20+
icons={{
21+
success: <CircleCheckIcon className="size-4" />,
22+
info: <InfoIcon className="size-4" />,
23+
warning: <TriangleAlertIcon className="size-4" />,
24+
error: <OctagonXIcon className="size-4" />,
25+
loading: <Loader2Icon className="size-4 animate-spin" />,
26+
}}
27+
style={
28+
{
29+
"--normal-bg": "var(--popover)",
30+
"--normal-text": "var(--popover-foreground)",
31+
"--normal-border": "var(--border)",
32+
"--border-radius": "var(--radius)",
33+
"--width": "300px",
34+
} as React.CSSProperties
35+
}
36+
{...props}
37+
/>
38+
)
39+
}
40+
41+
export { Toaster }

radius-proxy/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828
"jsonwebtoken": "^9.0.0",
2929
"lucide-react": "^0.544.0",
3030
"next": "15.5.4",
31+
"next-themes": "^0.4.6",
3132
"react": "19.1.0",
3233
"react-dom": "19.1.0",
34+
"sonner": "^2.0.7",
3335
"tailwind-merge": "^3.3.1"
3436
},
3537
"devDependencies": {

0 commit comments

Comments
 (0)