Dental Care

This commit is contained in:
Iliyan Angelov
2025-11-16 14:29:51 +02:00
commit 39077550ef
194 changed files with 43197 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
"use server";
import { auth } from "@/lib/auth-session/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
/**
* Server Actions for Authentication
*
* Best practices:
* - Use "use server" directive
* - Return consistent response shapes
* - Handle errors gracefully
* - Use Better Auth's API methods
*
* Note: Prefer using authClient on the client side when possible
* These are mainly for server-side flows or progressive enhancement
*/
/**
* Sign in with email and password
* @param email - User's email
* @param password - User's password
*/
export async function signInWithEmail(email: string, password: string) {
try {
await auth.api.signInEmail({
body: { email, password },
headers: await headers(),
});
return { success: true as const };
} catch (error) {
console.error("[signInWithEmail] Error:", error);
return {
success: false as const,
error: error instanceof Error ? error.message : "Sign in failed",
};
}
}
/**
* Sign up with email and password
* @param email - User's email
* @param password - User's password
* @param name - User's name
*/
export async function signUpWithEmail(
email: string,
password: string,
name: string
) {
try {
await auth.api.signUpEmail({
body: { email, password, name },
headers: await headers(),
});
return { success: true as const };
} catch (error) {
console.error("[signUpWithEmail] Error:", error);
return {
success: false as const,
error: error instanceof Error ? error.message : "Sign up failed",
};
}
}
/**
* Sign out the current user
*/
export async function signOutAction() {
try {
await auth.api.signOut({
headers: await headers(),
});
redirect("/sign-in");
} catch (error) {
console.error("[signOutAction] Error:", error);
return {
success: false as const,
error: "Sign out failed",
};
}
}
/**
* Get the current session
* Prefer using getSession() from auth-server.ts in Server Components
*/
export async function getCurrentSessionAction() {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
return {
success: true as const,
data: session,
};
} catch (error) {
console.error("[getCurrentSessionAction] Error:", error);
return {
success: false as const,
error: "Failed to get session",
data: null,
};
}
}

View File

@@ -0,0 +1,136 @@
import { createAuthClient } from "better-auth/react";
import { toast } from "sonner";
import { organizationClient } from "better-auth/client/plugins";
import { stripeClient } from "@better-auth/stripe/client";
/**
* Better Auth Client Configuration
*
* Best practices:
* - Don't set baseURL (use relative paths for same-origin cookies)
* - Always include credentials
* - Handle errors gracefully
* - Use plugins as needed
*/
export const authClient = createAuthClient({
// Use relative paths for same-origin requests
// baseURL is only needed if auth API is on different domain
fetchOptions: {
credentials: "include", // Include cookies in all requests
onError: async (context) => {
const { response, error } = context;
// Rate limiting
if (response?.status === 429) {
const retryAfter = response.headers.get("X-Retry-After");
toast.error(
`Too many requests. Please try again in ${retryAfter} seconds.`
);
return;
}
// Network errors
if (!response) {
toast.error("Network error. Please check your connection.");
return;
}
// Other errors
console.error("Auth error:", error);
},
},
// Plugins
plugins: [
organizationClient(),
stripeClient({
subscription: true,
}),
],
});
/**
* Export commonly used hooks and methods
*/
export const { useSession, signIn, signOut, signUp } = authClient;
/**
* Resend verification email
* @param email - User's email address
*/
export const resendVerificationEmail = async (email: string) => {
const res = await fetch("/api/auth/resend-verification", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
throw new Error("Failed to resend verification email.");
}
return res.json();
};
/**
* Sign in with email and password
* @param email - User's email address
* @param password - User's password
*/
export const signInWithEmail = async (email: string, password: string) => {
const data = await authClient.signIn.email(
{
email,
password,
},
{
onError: (ctx) => {
// Handle the error
if (ctx.error.status === 403) {
throw new Error("Please verify your email address");
}
throw new Error(ctx.error.message);
},
}
);
return data;
};
/**
* Sign in with Google using OAuth
* This will redirect the user to Google's consent screen
*/
export const signInWithGoogle = async () => {
const data = await authClient.signIn.social({
provider: "google",
});
return data;
};
/**
* Sign in with Google using ID Token
* Useful when you already have the Google ID Token from client-side
* @param token - Google ID Token
* @param accessToken - Google Access Token (optional)
*/
export const signInWithGoogleIdToken = async (
token: string,
accessToken?: string
) => {
const data = await authClient.signIn.social({
provider: "google",
idToken: {
token,
accessToken,
},
});
return data;
};
/**
* Request additional Google scopes (e.g., Google Drive, Gmail)
* @param scopes - Array of Google API scopes to request
*/
export const requestAdditionalGoogleScopes = async (scopes: string[]) => {
await authClient.linkSocial({
provider: "google",
scopes,
});
};

View File

@@ -0,0 +1,141 @@
import { redirect } from "next/navigation";
import { getServerSession } from "./get-session";
/**
* Server-side Authentication Helpers
*
* Best practices:
* - All functions use getServerSession (cached)
* - Redirects are handled gracefully
* - Role checks include proper fallbacks
* - Type-safe role checking
*/
/**
* Get the current session
* Cached to avoid multiple lookups in the same request
*/
export const getSession = getServerSession;
/**
* Require authentication in a Server Component or Server Action
* Redirects to /sign-in if not authenticated
* @returns The session object
*/
export async function requireAuth() {
const session = await getSession();
if (!session) {
redirect("/sign-in");
}
return session;
}
/**
* Get the current user
* @returns The user object or null if not authenticated
*/
export async function getCurrentUser() {
const session = await getSession();
return session?.user ?? null;
}
/**
* Check if the user is authenticated
* @returns true if authenticated, false otherwise
*/
export async function isAuthenticated() {
const session = await getSession();
return !!session;
}
/**
* Get the current user's role
* @returns The user's role or null
*/
export async function getUserRole() {
const session = await getSession();
return session?.user?.role ?? null;
}
/**
* Check if the current user is an admin
* @returns true if user is admin, false otherwise
*/
export async function isAdmin() {
const session = await getSession();
return session?.user?.role === "admin";
}
/**
* Check if the current user is a dentist
* @returns true if user is dentist, false otherwise
*/
export async function isDentist() {
const session = await getSession();
return session?.user?.role === "dentist";
}
/**
* Check if the current user is a patient
* @returns true if user is patient, false otherwise
*/
export async function isPatient() {
const session = await getSession();
return session?.user?.role === "patient";
}
/**
* Require admin role
* Redirects to appropriate page if not admin
* @returns The session object
*/
export async function requireAdmin() {
const session = await requireAuth();
if (session.user?.role !== "admin") {
const role = session.user?.role;
redirect(role === "dentist" ? "/dentist" : role === "patient" ? "/patient" : "/");
}
return session;
}
/**
* Require dentist role
* Redirects to appropriate page if not dentist
* @returns The session object
*/
export async function requireDentist() {
const session = await requireAuth();
if (session.user?.role !== "dentist") {
const role = session.user?.role;
redirect(role === "admin" ? "/admin" : role === "patient" ? "/patient" : "/");
}
return session;
}
/**
* Require patient role
* Redirects to appropriate page if not patient
* @returns The session object
*/
export async function requirePatient() {
const session = await requireAuth();
if (session.user?.role !== "patient") {
const role = session.user?.role;
redirect(role === "admin" ? "/admin" : role === "dentist" ? "/dentist" : "/");
}
return session;
}
/**
* Require staff role (admin or dentist)
* Redirects to patient portal if neither
* @returns The session object
*/
export async function requireStaff() {
const session = await requireAuth();
const role = session.user?.role;
if (role !== "admin" && role !== "dentist") {
redirect(role === "patient" ? "/patient" : "/");
}
return session;
}

121
lib/auth-session/auth.ts Normal file
View File

@@ -0,0 +1,121 @@
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { Resend } from "resend";
import ForgotPasswordEmail from "@/components/emails/reset-password";
import VerificationEmail from "@/components/emails/email-verification";
import { prisma } from "@/lib/types/prisma";
const resend = new Resend(process.env.RESEND_API_KEY!);
/**
* Better Auth Configuration
*
* Best practices:
* - Use environment variables for secrets and URLs
* - Enable secure cookies in production
* - Use rolling sessions for better UX
* - Cache sessions to reduce database calls
*/
export const auth = betterAuth({
// Core configuration
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:3000",
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
],
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 30, // 30 days
updateAge: 60 * 60 * 24, // Update session every 24 hours
cookieCache: {
enabled: true,
maxAge: 5 * 60, // Cache for 5 minutes
},
},
// Advanced security settings
advanced: {
useSecureCookies: process.env.NODE_ENV === "production",
cookiePrefix: "better-auth",
crossSubDomainCookies: {
enabled: true, // Works across www and non-www
},
defaultCookieAttributes: {
sameSite: "lax", // CSRF protection while allowing normal navigation
path: "/",
// httpOnly is automatically set by Better Auth
secure: process.env.NODE_ENV === "production", // HTTPS only in production
},
},
// Database adapter
database: prismaAdapter(prisma, {
provider: "mongodb",
}),
// Email & Password authentication
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
autoSignIn: false, // Don't auto sign-in until email verified
sendResetPassword: async ({ user, url }) => {
await resend.emails.send({
from: `${process.env.EMAIL_SENDER_NAME || "Dental U Care"} <${process.env.EMAIL_SENDER_ADDRESS || "send@dentalucare.tech"}>`,
to: user.email,
subject: "Reset your password",
react: ForgotPasswordEmail({ username: user.name, resetUrl: url }),
});
},
},
// Email verification
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
await resend.emails.send({
from: `${process.env.EMAIL_SENDER_NAME || "Dental U Care"} <${process.env.EMAIL_SENDER_ADDRESS || "send@dentalucare.tech"}>`,
to: user.email,
subject: "Verify your email",
react: VerificationEmail({
username: user.name,
verificationUrl: url,
}),
});
},
},
// User model configuration
user: {
additionalFields: {
role: {
type: "string",
required: false,
defaultValue: "patient",
input: false, // Don't allow direct input
},
},
},
// Social authentication providers
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
prompt: "select_account", // Always show account selector
},
},
// Lifecycle hooks
onAfterSignUp: async ({ user }: { user: { id: string; role?: string } }) => {
// Ensure new users have a role
if (!user.role) {
await prisma.user.update({
where: { id: user.id },
data: { role: "patient" },
});
}
},
// Plugins (nextCookies must be last)
plugins: [nextCookies()],
});

View File

@@ -0,0 +1,43 @@
import { headers } from "next/headers";
import { cache } from "react";
import { auth } from "./auth";
import { prisma } from "@/lib/types/prisma";
/**
* Get the current session in a Server Component or Server Action
*
* Best practices:
* - Cached to avoid multiple lookups in the same request
* - Includes user role from database
* - Returns null if not authenticated
* - Use this in Server Components and Server Actions
*
* @returns The session object with user role or null if not authenticated
*/
export const getServerSession = cache(async () => {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || !session.user) {
return null;
}
// Fetch role from database if not in session
// Better Auth's session cache may not include custom fields
if (!session.user.role) {
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true },
});
if (dbUser) {
session.user.role = dbUser.role || "patient";
}
}
return session;
} catch (error) {
console.error("[getServerSession] Error:", error);
return null;
}
});