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:
Claude
2026-06-02 21:28:02 +03:00
parent dec150c77d
commit 1ecbd16404
31 changed files with 1853 additions and 286 deletions
+10 -7
View File
@@ -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>
);
}
+108
View File
@@ -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>
);
}
+8
View File
@@ -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>;
}
+93
View File
@@ -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>
);
}
+98
View File
@@ -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>
);
}
+114
View File
@@ -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>
);
}
+121
View File
@@ -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>
);
}
+95
View File
@@ -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&apos;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>
);
}