Dental Care
This commit is contained in:
110
lib/auth-session/auth-actions.ts
Normal file
110
lib/auth-session/auth-actions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
136
lib/auth-session/auth-client.ts
Normal file
136
lib/auth-session/auth-client.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
141
lib/auth-session/auth-server.ts
Normal file
141
lib/auth-session/auth-server.ts
Normal 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
121
lib/auth-session/auth.ts
Normal 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()],
|
||||
});
|
||||
43
lib/auth-session/get-session.ts
Normal file
43
lib/auth-session/get-session.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user