mirror of
https://github.com/temetro/temetro.git
synced 2026-08-26 02:17:22 +00:00
Wire frontend to the backend: auth, organizations, real patient API
Connects the UI-only app to the new backend over Better Auth + a small patient API client, replacing the in-memory fixture and placeholder identity. - Better Auth React client (lib/auth-client.ts) + shared access-control roles; API client (lib/api-client.ts) sending credentials cross-origin. - Designed (auth) route group: login, signup, verify-email, forgot/reset password, clinic onboarding, and accept-invite. - proxy.ts (this Next's renamed middleware) optimistic redirect + an authoritative client AppAuthGuard requiring a session and active clinic. - Real identity in nav-user (useSession + sign out); the unused team switcher repurposed as an organization (clinic) switcher; Care-team settings tab manages members and invitations. - lib/patients.ts now calls the org-scoped API (types unchanged); patients table and create/edit dialog updated for async create/update. - next.config: standalone output + Dockerfile for containerized runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AppAuthGuard } from "@/components/auth/app-auth-guard";
|
||||
import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
@@ -7,11 +8,13 @@ export default function AppLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="relative flex h-dvh w-full">
|
||||
<DashboardSidebar />
|
||||
{children}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
<AppAuthGuard>
|
||||
<SidebarProvider>
|
||||
<div className="relative flex h-dvh w-full">
|
||||
<DashboardSidebar />
|
||||
{children}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</AppAuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
|
||||
import { AuthShell, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
function AcceptInviteInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const invitationId = params.get("id") ?? "";
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
|
||||
const accept = async () => {
|
||||
if (!invitationId || accepting) return;
|
||||
setAccepting(true);
|
||||
setError(null);
|
||||
const { data, error: err } = await authClient.organization.acceptInvitation({
|
||||
invitationId,
|
||||
});
|
||||
if (err || !data) {
|
||||
setError(err?.message ?? "This invitation is invalid or has expired.");
|
||||
setAccepting(false);
|
||||
return;
|
||||
}
|
||||
const orgId = data.invitation?.organizationId;
|
||||
if (orgId) {
|
||||
await authClient.organization.setActive({ organizationId: orgId });
|
||||
}
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
if (!invitationId) {
|
||||
return (
|
||||
<AuthShell title="Invitation not found">
|
||||
<FormAlert>This invitation link is missing or invalid.</FormAlert>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
// Must be signed in (with the invited email) to accept.
|
||||
if (!isPending && !session?.user) {
|
||||
const back = `/accept-invite?id=${encodeURIComponent(invitationId)}`;
|
||||
return (
|
||||
<AuthShell
|
||||
subtitle="Sign in with the email this invitation was sent to, then return to this link to accept."
|
||||
title="You've been invited"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push("/login")}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push("/signup")}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
After signing in, reopen{" "}
|
||||
<Link className="hover:underline" href={back}>
|
||||
this invitation link
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
subtitle="Join the clinic you were invited to on temetro."
|
||||
title="Accept your invitation"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={accepting}
|
||||
onClick={accept}
|
||||
type="button"
|
||||
>
|
||||
{accepting ? "Joining…" : "Accept invitation"}
|
||||
</Button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AcceptInviteInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const { error: err } = await authClient.requestPasswordReset({
|
||||
email: email.trim(),
|
||||
redirectTo: `${window.location.origin}/reset-password`,
|
||||
});
|
||||
|
||||
setSubmitting(false);
|
||||
if (err) {
|
||||
setError(err.message ?? "Could not send the reset email.");
|
||||
return;
|
||||
}
|
||||
setSent(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
footer={
|
||||
<Link className="text-foreground hover:underline" href="/login">
|
||||
Back to sign in
|
||||
</Link>
|
||||
}
|
||||
subtitle="We'll email you a link to reset your password"
|
||||
title="Reset your password"
|
||||
>
|
||||
{sent ? (
|
||||
<FormAlert tone="success">
|
||||
If an account exists for {email}, a reset link is on its way. Check
|
||||
your inbox.
|
||||
</FormAlert>
|
||||
) : (
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field htmlFor="email" label="Email">
|
||||
<Input
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@clinic.org"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</Field>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Sending…" : "Send reset link"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// No app chrome (sidebar) on auth screens — just a scrollable centered area.
|
||||
return <main className="flex-1 overflow-y-auto">{children}</main>;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const { error: err } = await authClient.signIn.email({
|
||||
email: email.trim(),
|
||||
password,
|
||||
callbackURL: `${window.location.origin}/`,
|
||||
});
|
||||
|
||||
if (err) {
|
||||
setError(
|
||||
err.message ??
|
||||
"Could not sign in. Check your email and password and try again."
|
||||
);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
footer={
|
||||
<>
|
||||
New to temetro?{" "}
|
||||
<Link className="text-foreground hover:underline" href="/signup">
|
||||
Create an account
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
subtitle="Sign in to your clinician account"
|
||||
title="Welcome back"
|
||||
>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field htmlFor="email" label="Email">
|
||||
<Input
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@clinic.org"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</Field>
|
||||
<Field htmlFor="password" label="Password">
|
||||
<Input
|
||||
autoComplete="current-password"
|
||||
id="password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</Field>
|
||||
<div className="-mt-1 text-right">
|
||||
<Link
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
href="/forgot-password"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
|
||||
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugEdited, setSlugEdited] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Send unauthenticated users to login. Authenticated users (whether brand
|
||||
// new or creating an additional clinic) stay on this page.
|
||||
useEffect(() => {
|
||||
if (isPending) return;
|
||||
if (!session?.user) router.replace("/login");
|
||||
}, [session, isPending, router]);
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const finalSlug = (slugEdited ? slug : slugify(name)) || slugify(name);
|
||||
const { data: org, error: createErr } = await authClient.organization.create(
|
||||
{ name: name.trim(), slug: finalSlug }
|
||||
);
|
||||
|
||||
if (createErr || !org) {
|
||||
setError(createErr?.message ?? "Could not create the clinic.");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await authClient.organization.setActive({ organizationId: org.id });
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
subtitle="Create your clinic to start organizing patient records"
|
||||
title="Set up your clinic"
|
||||
>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field htmlFor="name" label="Clinic name">
|
||||
<Input
|
||||
id="name"
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (!slugEdited) setSlug(slugify(e.target.value));
|
||||
}}
|
||||
placeholder="North Side Family Practice"
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
hint="Used in links and invitations. Lowercase letters, numbers and dashes."
|
||||
htmlFor="slug"
|
||||
label="Clinic URL slug"
|
||||
>
|
||||
<Input
|
||||
id="slug"
|
||||
onChange={(e) => {
|
||||
setSlugEdited(true);
|
||||
setSlug(slugify(e.target.value));
|
||||
}}
|
||||
placeholder="north-side-family-practice"
|
||||
required
|
||||
value={slug}
|
||||
/>
|
||||
</Field>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Creating clinic…" : "Create clinic"}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { type FormEvent, Suspense, useState } from "react";
|
||||
|
||||
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const MIN_PASSWORD = 12;
|
||||
|
||||
function ResetPasswordInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const token = params.get("token") ?? "";
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
setError(null);
|
||||
|
||||
if (!token) {
|
||||
setError("This reset link is invalid or has expired.");
|
||||
return;
|
||||
}
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
setError(`Password must be at least ${MIN_PASSWORD} characters.`);
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const { error: err } = await authClient.resetPassword({
|
||||
newPassword: password,
|
||||
token,
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (err) {
|
||||
setError(err.message ?? "Could not reset your password.");
|
||||
return;
|
||||
}
|
||||
setDone(true);
|
||||
setTimeout(() => router.push("/login"), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
footer={
|
||||
<Link className="text-foreground hover:underline" href="/login">
|
||||
Back to sign in
|
||||
</Link>
|
||||
}
|
||||
subtitle="Choose a new password for your account"
|
||||
title="Set a new password"
|
||||
>
|
||||
{done ? (
|
||||
<FormAlert tone="success">
|
||||
Your password has been reset. Redirecting you to sign in…
|
||||
</FormAlert>
|
||||
) : (
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field
|
||||
hint={`At least ${MIN_PASSWORD} characters.`}
|
||||
htmlFor="password"
|
||||
label="New password"
|
||||
>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id="password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</Field>
|
||||
<Field htmlFor="confirm" label="Confirm new password">
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id="confirm"
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
type="password"
|
||||
value={confirm}
|
||||
/>
|
||||
</Field>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Saving…" : "Reset password"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const MIN_PASSWORD = 12;
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
setError(null);
|
||||
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
setError(`Password must be at least ${MIN_PASSWORD} characters.`);
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
const { error: err } = await authClient.signUp.email({
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
callbackURL: `${window.location.origin}/verify-email`,
|
||||
});
|
||||
|
||||
if (err) {
|
||||
setError(err.message ?? "Could not create your account.");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
router.push(`/verify-email?email=${encodeURIComponent(email.trim())}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
footer={
|
||||
<>
|
||||
Already have an account?{" "}
|
||||
<Link className="text-foreground hover:underline" href="/login">
|
||||
Sign in
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
subtitle="Start using temetro in your clinic"
|
||||
title="Create your account"
|
||||
>
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field htmlFor="name" label="Full name">
|
||||
<Input
|
||||
autoComplete="name"
|
||||
id="name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Dr. Jane Okafor"
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</Field>
|
||||
<Field htmlFor="email" label="Email">
|
||||
<Input
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@clinic.org"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
hint={`At least ${MIN_PASSWORD} characters.`}
|
||||
htmlFor="password"
|
||||
label="Password"
|
||||
>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id="password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</Field>
|
||||
<Field htmlFor="confirm" label="Confirm password">
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id="confirm"
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
type="password"
|
||||
value={confirm}
|
||||
/>
|
||||
</Field>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Creating account…" : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
|
||||
import { AuthShell, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
function VerifyEmailInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const email = params.get("email") ?? "";
|
||||
const { data: session } = authClient.useSession();
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// After the emailed link verifies the address, Better Auth auto-signs the
|
||||
// user in and redirects here — at which point a session exists.
|
||||
useEffect(() => {
|
||||
if (session?.user) router.replace("/");
|
||||
}, [session, router]);
|
||||
|
||||
const resend = async () => {
|
||||
if (!email || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
const { error: err } = await authClient.sendVerificationEmail({
|
||||
email,
|
||||
callbackURL: `${window.location.origin}/verify-email`,
|
||||
});
|
||||
setSending(false);
|
||||
if (err) {
|
||||
setError(err.message ?? "Could not resend the verification email.");
|
||||
return;
|
||||
}
|
||||
setNotice("Verification email sent. Check your inbox.");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
footer={
|
||||
<Link className="text-foreground hover:underline" href="/login">
|
||||
Back to sign in
|
||||
</Link>
|
||||
}
|
||||
subtitle={
|
||||
email ? (
|
||||
<>
|
||||
We sent a verification link to{" "}
|
||||
<span className="text-foreground">{email}</span>. Open it to activate
|
||||
your account.
|
||||
</>
|
||||
) : (
|
||||
"Open the verification link we emailed you to activate your account."
|
||||
)
|
||||
}
|
||||
title="Check your inbox"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{notice && <FormAlert tone="success">{notice}</FormAlert>}
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push("/")}
|
||||
type="button"
|
||||
>
|
||||
I've verified — continue
|
||||
</Button>
|
||||
{email && (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={sending}
|
||||
onClick={resend}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{sending ? "Sending…" : "Resend email"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<VerifyEmailInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user