refactor: migrate authentication system and update Drizzle schema.

This commit is contained in:
2025-11-29 02:26:26 -05:00
parent c88e5d9d82
commit 3ebec7aa4a
36 changed files with 603 additions and 440 deletions

View File

@@ -0,0 +1,4 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "~/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -1,3 +0,0 @@
import { handlers } from "~/server/auth";
export const { GET, POST } = handlers;

View File

@@ -2,7 +2,7 @@
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { signIn } from "next-auth/react";
import { authClient } from "~/lib/auth-client";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
@@ -31,16 +31,15 @@ function SignInForm() {
e.preventDefault();
setLoading(true);
const result = await signIn("credentials", {
const { error } = await authClient.signIn.email({
email,
password,
redirect: false,
});
setLoading(false);
if (result?.error) {
toast.error("Invalid email or password");
if (error) {
toast.error(error.message ?? "Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);

View File

@@ -136,9 +136,9 @@ export default function SendEmailPage() {
action:
canRetry && retryCount < 2
? {
label: "Retry",
onClick: () => handleRetry(),
}
label: "Retry",
onClick: () => handleRetry(),
}
: undefined,
});
@@ -150,31 +150,31 @@ export default function SendEmailPage() {
const invoice = useMemo(() => {
return invoiceData
? {
id: invoiceData.id,
invoiceNumber: invoiceData.invoiceNumber,
issueDate: invoiceData.issueDate,
dueDate: invoiceData.dueDate,
status: invoiceData.status,
taxRate: invoiceData.taxRate,
client: invoiceData.client
? {
name: invoiceData.client.name,
email: invoiceData.client.email,
}
: undefined,
business: invoiceData.business
? {
name: invoiceData.business.name,
nickname: invoiceData.business.nickname,
email: invoiceData.business.email,
}
: undefined,
items: invoiceData.items?.map((item) => ({
id: item.id,
hours: item.hours,
rate: item.rate,
})),
}
id: invoiceData.id,
invoiceNumber: invoiceData.invoiceNumber,
issueDate: invoiceData.issueDate,
dueDate: invoiceData.dueDate,
status: invoiceData.status,
taxRate: invoiceData.taxRate,
client: invoiceData.client
? {
name: invoiceData.client.name,
email: invoiceData.client.email,
}
: undefined,
business: invoiceData.business
? {
name: invoiceData.business.name,
nickname: invoiceData.business.nickname,
email: invoiceData.business.email,
}
: undefined,
items: invoiceData.items?.map((item) => ({
id: item.id,
hours: item.hours,
rate: item.rate,
})),
}
: undefined;
}, [invoiceData]);
@@ -184,6 +184,7 @@ export default function SendEmailPage() {
// Set default subject
const defaultSubject = `Invoice ${invoice.invoiceNumber} from ${invoice.business?.name ?? "Your Business"}`;
// eslint-disable-next-line react-hooks/set-state-in-effect
setSubject(defaultSubject);
// Set default content (empty since template handles everything)

View File

@@ -16,7 +16,8 @@ import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton";
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
import { auth } from "~/server/auth";
import { auth } from "~/lib/auth";
import { headers } from "next/headers";
import { HydrateClient, api } from "~/trpc/server";
import type { StoredInvoiceStatus } from "~/types/invoice";
import { RevenueChart } from "~/app/dashboard/_components/revenue-chart";
@@ -126,15 +127,15 @@ async function DashboardStats() {
const pendingChange =
lastMonthInvoices.length > 0
? ((pendingInvoices.length -
lastMonthInvoices.filter((invoice) => {
const status = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
return status === "sent" || status === "overdue";
}).length) /
lastMonthInvoices.length) *
100
lastMonthInvoices.filter((invoice) => {
const status = getEffectiveInvoiceStatus(
invoice.status as StoredInvoiceStatus,
invoice.dueDate,
);
return status === "sent" || status === "overdue";
}).length) /
lastMonthInvoices.length) *
100
: pendingInvoices.length > 0
? 100
: 0;
@@ -323,11 +324,10 @@ function QuickActions() {
<Link
key={action.title}
href={action.href}
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${
action.featured
? "border-foreground/20 bg-muted/50 hover:bg-muted"
: "border-border bg-background hover:bg-muted/50"
}`}
className={`hover-lift flex w-full items-start space-x-3 rounded-lg border p-4 transition-colors ${action.featured
? "border-foreground/20 bg-muted/50 hover:bg-muted"
: "border-border bg-background hover:bg-muted/50"
}`}
>
<Icon className="h-5 w-5 flex-shrink-0" />
<div className="min-w-0 flex-1">
@@ -624,7 +624,9 @@ function CardSkeleton() {
}
export default async function DashboardPage() {
const session = await auth();
const session = await auth.api.getSession({
headers: await headers(),
});
const firstName = session?.user?.name?.split(" ")[0] ?? "User";
return (

View File

@@ -18,7 +18,7 @@ import {
User,
Users,
} from "lucide-react";
import { useSession } from "next-auth/react";
import { authClient } from "~/lib/auth-client";
import * as React from "react";
import { useState } from "react";
@@ -66,7 +66,7 @@ import { AppearanceSettings } from "./appearance-settings";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
export function SettingsContent() {
const { data: session } = useSession();
const { data: session } = authClient.useSession();
const [name, setName] = useState("");
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState("");

View File

@@ -41,6 +41,7 @@ export function ThemeSelector() {
"h-8 w-8 rounded-full border-2",
colorTheme === t.name && "border-primary",
)}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
onClick={() => setColorTheme(t.name as any)}
style={{ backgroundColor: t.hex }}
>

View File

@@ -1,19 +1,19 @@
"use client";
import { useSession } from "next-auth/react";
import { authClient } from "~/lib/auth-client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function AuthRedirect() {
const { data: session, status } = useSession();
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
useEffect(() => {
// Only redirect if we're sure the user is authenticated
if (status === "authenticated" && session?.user) {
if (!isPending && session?.user) {
router.push("/dashboard");
}
}, [session, status, router]);
}, [session, isPending, router]);
// This component doesn't render anything
return null;

View File

@@ -17,19 +17,12 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
xl: "text-4xl",
};
const LogoContent = () => (
<div className={cn("flex items-center", sizeClasses[size], className)}>
<span className="text-primary font-bold tracking-tight">$</span>
<span className="inline-block w-2"></span>
<span className="text-foreground font-bold tracking-tight">been</span>
<span className="text-foreground/70 font-bold tracking-tight">voice</span>
</div>
);
if (!animated) {
return <LogoContent />;
return <LogoContent className={className} size={size} sizeClasses={sizeClasses} />;
}
return (
<motion.div
initial={{ opacity: 0 }}
@@ -70,3 +63,22 @@ export function Logo({ className, size = "md", animated = true }: LogoProps) {
</motion.div>
);
}
function LogoContent({
className,
size,
sizeClasses,
}: {
className?: string;
size: "sm" | "md" | "lg" | "xl";
sizeClasses: Record<string, string>;
}) {
return (
<div className={cn("flex items-center", sizeClasses[size], className)}>
<span className="text-primary font-bold tracking-tight">$</span>
<span className="inline-block w-2"></span>
<span className="text-foreground font-bold tracking-tight">been</span>
<span className="text-foreground/70 font-bold tracking-tight">voice</span>
</div>
);
}

View File

@@ -140,6 +140,7 @@ export function DataTable<TData, TValue>({
}));
}, [columns]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns: responsiveColumns,
@@ -359,9 +360,9 @@ export function DataTable<TData, TValue>({
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
@@ -427,28 +428,26 @@ export function DataTable<TData, TValue>({
<p className="text-muted-foreground hidden text-xs sm:inline sm:text-sm">
{table.getFilteredRowModel().rows.length === 0
? "No entries"
: `Showing ${
table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1
} to ${Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length} entries`}
: `Showing ${table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1
} to ${Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length} entries`}
</p>
<p className="text-muted-foreground text-xs sm:hidden">
{table.getFilteredRowModel().rows.length === 0
? "0"
: `${
table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1
}-${Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length}`}
: `${table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1
}-${Math.min(
(table.getState().pagination.pageIndex + 1) *
table.getState().pagination.pageSize,
table.getFilteredRowModel().rows.length,
)} of ${table.getFilteredRowModel().rows.length}`}
</p>
<Select
value={table.getState().pagination.pageSize.toString()}

View File

@@ -87,9 +87,8 @@ function SortableItem({
<div
ref={setNodeRef}
style={style}
className={`card-secondary transition-colors ${
isDragging ? "opacity-50 shadow-lg" : ""
}`}
className={`card-secondary transition-colors ${isDragging ? "opacity-50 shadow-lg" : ""
}`}
>
{/* Desktop Layout - Hidden on Mobile */}
<div className="hidden items-center gap-3 p-4 md:grid md:grid-cols-12">
@@ -287,6 +286,7 @@ export function EditableInvoiceItems({
const [isClient, setIsClient] = useState(false);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsClient(true);
}, []);

View File

@@ -1,15 +1,17 @@
"use client";
import { signOut, useSession } from "next-auth/react";
import { authClient } from "~/lib/auth-client";
import Link from "next/link";
import { useState } from "react";
import { Logo } from "~/components/branding/logo";
import { SidebarTrigger } from "~/components/navigation/sidebar-trigger";
import { Button } from "~/components/ui/button";
import { Skeleton } from "~/components/ui/skeleton";
import { useRouter } from "next/navigation";
export function Navbar() {
const { data: session, status } = useSession();
const { data: session, isPending } = authClient.useSession();
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
const router = useRouter();
// Get current open invoice for quick access
// const { data: currentInvoice } = api.invoices.getCurrentOpen.useQuery();
@@ -27,7 +29,7 @@ export function Navbar() {
</Link>
</div>
<div className="flex items-center gap-2 md:gap-4">
{status === "loading" ? (
{isPending ? (
<>
<Skeleton className="bg-muted/20 hidden h-5 w-20 sm:inline" />
<Skeleton className="bg-muted/20 h-8 w-16" />
@@ -40,7 +42,10 @@ export function Navbar() {
<Button
variant="outline"
size="sm"
onClick={() => signOut({ callbackUrl: "/" })}
onClick={async () => {
await authClient.signOut();
router.push("/");
}}
className="text-xs md:text-sm"
>
Sign Out

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession, signOut } from "next-auth/react";
import { authClient } from "~/lib/auth-client";
import { Skeleton } from "~/components/ui/skeleton";
import { Button } from "~/components/ui/button";
import { LogOut, User } from "lucide-react";
@@ -10,7 +10,7 @@ import { navigationConfig } from "~/lib/navigation";
export function Sidebar() {
const pathname = usePathname();
const { data: session, status } = useSession();
const { data: session, isPending } = authClient.useSession();
return (
<aside className="bg-sidebar border-sidebar-border text-sidebar-foreground fixed top-[4rem] bottom-0 left-0 z-20 hidden w-64 flex-col justify-between border-r p-6 md:flex">
@@ -24,7 +24,7 @@ export function Sidebar() {
{section.title}
</div>
<div className="flex flex-col gap-0.5">
{status === "loading" ? (
{isPending ? (
<>
{Array.from({ length: section.links.length }).map((_, i) => (
<div
@@ -44,11 +44,10 @@ export function Sidebar() {
key={link.href}
href={link.href}
aria-current={pathname === link.href ? "page" : undefined}
className={`flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors ${
pathname === link.href
className={`flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors ${pathname === link.href
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
}`}
}`}
>
<Icon className="h-4 w-4" />
{link.name}
@@ -63,7 +62,7 @@ export function Sidebar() {
{/* User Section */}
<div className="border-sidebar-border border-t pt-4">
{status === "loading" ? (
{isPending ? (
<div className="space-y-3">
<Skeleton className="bg-sidebar-accent/20 h-8 w-full" />
<Skeleton className="bg-sidebar-accent/20 h-8 w-full" />
@@ -80,7 +79,7 @@ export function Sidebar() {
<Button
variant="ghost"
size="sm"
onClick={() => signOut()}
onClick={() => authClient.signOut()}
className="text-sidebar-foreground/60 hover:text-sidebar-foreground hover:bg-sidebar-accent w-full justify-start px-3"
>
<LogOut className="mr-2 h-4 w-4" />

View File

@@ -1,7 +1,7 @@
"use client";
import { MenuIcon, X } from "lucide-react";
import { useSession } from "next-auth/react";
import { authClient } from "~/lib/auth-client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button } from "~/components/ui/button";
@@ -15,7 +15,7 @@ interface SidebarTriggerProps {
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
const pathname = usePathname();
const { status } = useSession();
const { isPending } = authClient.useSession();
return (
<>
@@ -46,7 +46,7 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
{section.title}
</div>
<div className="flex flex-col gap-0.5">
{status === "loading" ? (
{isPending ? (
<>
{Array.from({ length: section.links.length }).map(
(_, i) => (
@@ -70,11 +70,10 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
aria-current={
pathname === link.href ? "page" : undefined
}
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${
pathname === link.href
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${pathname === link.href
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-muted"
}`}
}`}
onClick={onToggle}
>
<Icon className="h-4 w-4" />

View File

@@ -53,7 +53,7 @@ import React, {
useState,
} from "react";
import { api } from "~/trpc/react";
import { useSession } from "next-auth/react";
import { authClient } from "~/lib/auth-client";
type AnimationPreferences = {
prefersReducedMotion: boolean;
@@ -175,7 +175,7 @@ export function AnimationPreferencesProvider({
autoSync = true,
}: AnimationPreferencesProviderProps) {
const updateMutation = api.settings.updateAnimationPreferences.useMutation();
const { data: session } = useSession();
const { data: session } = authClient.useSession();
const isAuthed = !!session?.user;
// Server query only when authenticated
const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery(
@@ -216,8 +216,8 @@ export function AnimationPreferencesProvider({
DEFAULT_PREFERS_REDUCED;
const finalSpeed = clampSpeed(
stored?.animationSpeedMultiplier ??
initial?.animationSpeedMultiplier ??
DEFAULT_SPEED,
initial?.animationSpeedMultiplier ??
DEFAULT_SPEED,
);
setPrefersReducedMotion(finalPrefers);

View File

@@ -114,8 +114,11 @@ export function ColorThemeProvider({
const savedColorTheme = localStorage.getItem("color-theme") as ColorTheme | null;
if (isCustom && savedThemeData) {
const themeData = JSON.parse(savedThemeData);
if (themeData && themeData.color && themeData.colors) {
const themeData = JSON.parse(savedThemeData) as {
color: string;
colors: Record<string, string>;
};
if (themeData?.color && themeData.colors) {
setColorTheme("custom", themeData.color);
return;
}

View File

@@ -40,6 +40,7 @@ export function AccentColorSwitcher() {
}, [colorTheme, savedCustomColor]);
const handleColorChange = (color: { name: string; hex: string }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
setColorTheme(color.name.toLowerCase() as any);
};
@@ -74,8 +75,8 @@ export function AccentColorSwitcher() {
className={cn(
"h-10 w-10 rounded-lg border-2",
colorTheme === color.name.toLowerCase() &&
!isCustom &&
"border-primary ring-primary ring-2 ring-offset-2",
!isCustom &&
"border-primary ring-primary ring-2 ring-offset-2",
isCustom && "opacity-50",
)}
onClick={() => handleColorChange(color)}

View File

@@ -23,7 +23,38 @@ export function LegalModal({ type, trigger }: LegalModalProps) {
const isTerms = type === "terms";
const title = isTerms ? "Terms of Service" : "Privacy Policy";
const TermsContent = () => (
return (
<Dialog open={open} onOpenChange={setOpen}>
<span className="inline" onClick={() => setOpen(true)}>
{trigger}
</span>
<DialogContent className="max-h-[80vh] max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
{title}
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(false)}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-full max-h-[60vh] pr-4">
{isTerms ? <TermsContent /> : <PrivacyContent />}
</ScrollArea>
<div className="flex justify-end pt-4">
<Button onClick={() => setOpen(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
);
}
function TermsContent() {
return (
<div className="space-y-6">
<Card>
<CardHeader>
@@ -169,8 +200,10 @@ export function LegalModal({ type, trigger }: LegalModalProps) {
</Card>
</div>
);
}
const PrivacyContent = () => (
function PrivacyContent() {
return (
<div className="space-y-6">
<Card>
<CardHeader>
@@ -314,33 +347,4 @@ export function LegalModal({ type, trigger }: LegalModalProps) {
</Card>
</div>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<span className="inline" onClick={() => setOpen(true)}>
{trigger}
</span>
<DialogContent className="max-h-[80vh] max-w-6xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
{title}
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(false)}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-full max-h-[60vh] pr-4">
{isTerms ? <TermsContent /> : <PrivacyContent />}
</ScrollArea>
<div className="flex justify-end pt-4">
<Button onClick={() => setOpen(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -265,7 +265,7 @@ function SelectContentWithSearch({
)}
<SelectScrollUpButton />
<SelectPrimitive.Viewport className="p-1">
{filteredOptions && filteredOptions.length === 0 ? (
{filteredOptions?.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-sm select-none">
No results found
</div>

5
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL,
});

31
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,31 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db } from "~/server/db";
import * as schema from "~/server/db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema: {
user: schema.users,
session: schema.sessions,
account: schema.accounts,
verification: schema.verificationTokens,
},
}),
emailAndPassword: {
enabled: true,
password: {
hash: async (password) => {
const bcrypt = await import("bcryptjs");
return bcrypt.hash(password, 12);
},
verify: async ({ hash, password }) => {
const bcrypt = await import("bcryptjs");
return bcrypt.compare(password, hash);
},
},
},
plugins: [nextCookies()],
});

View File

@@ -36,9 +36,8 @@ export function generateAccentColors(hex: string) {
"--popover": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--primary": `oklch(0.6 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`,
@@ -57,9 +56,8 @@ export function generateAccentColors(hex: string) {
"--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`,
"--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
@@ -77,13 +75,11 @@ export function generateAccentColors(hex: string) {
"--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`,
"--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--primary": `oklch(0.7 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2
} ${base.h})`,
"--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`,
"--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`,
"--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`,
@@ -100,9 +96,8 @@ export function generateAccentColors(hex: string) {
"--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
@@ -195,7 +190,7 @@ function multiplyMatrix(
matrix: number[][],
vector: number[],
): [number, number, number] {
const result = new Array(matrix.length).fill(0);
const result = new Array(matrix.length).fill(0) as number[];
for (let i = 0; i < matrix.length; i++) {
for (let j = 0; j < vector.length; j++) {
result[i]! += matrix[i]![j]! * vector[j]!;

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Define public routes that don't require authentication
@@ -20,12 +20,10 @@ export function middleware(request: NextRequest) {
return NextResponse.next();
}
// Check for session token in cookies (Auth.js v5 cookie names)
// Check for session token in cookies (Better Auth cookie names)
const sessionToken =
request.cookies.get("authjs.session-token")?.value ??
request.cookies.get("__Secure-authjs.session-token")?.value ??
request.cookies.get("next-auth.session-token")?.value ??
request.cookies.get("__Secure-next-auth.session-token")?.value;
request.cookies.get("better-auth.session_token")?.value ??
request.cookies.get("__Secure-better-auth.session_token")?.value;
// If no session token, redirect to sign-in
if (!sessionToken) {

View File

@@ -78,7 +78,7 @@ export const invoicesRouter = createTRPCRouter({
});
// Return null if no draft invoice exists
if (!currentInvoice || currentInvoice.status !== "draft") {
if (currentInvoice?.status !== "draft") {
return null;
}

View File

@@ -11,7 +11,7 @@ import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "~/server/auth";
import { auth } from "~/lib/auth";
import { db } from "~/server/db";
/**
@@ -27,7 +27,9 @@ import { db } from "~/server/db";
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth();
const session = await auth.api.getSession({
headers: opts.headers,
});
return {
db,

View File

@@ -1,119 +0,0 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { type DefaultSession, type NextAuthConfig } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { db } from "~/server/db";
import {
accounts,
sessions,
users,
verificationTokens,
} from "~/server/db/schema";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
// ...other properties
// role: UserRole;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
}
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
*
* @see https://next-auth.js.org/configuration/options
*/
export const authConfig = {
session: {
strategy: "jwt",
},
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
if (
typeof credentials.email !== "string" ||
typeof credentials.password !== "string"
) {
return null;
}
const user = await db.query.users.findFirst({
where: eq(users.email, credentials.email),
});
if (!user?.password) {
return null;
}
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.password,
);
if (!isPasswordValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
callbacks: {
session: ({ session, token }) => ({
...session,
user: {
...session.user,
id: token.sub,
},
}),
jwt: ({ token, user }) => {
if (user) {
token.sub = user.id;
}
return token;
},
redirect: ({ url, baseUrl }) => {
// Allows relative callback URLs
if (url.startsWith("/")) return `${baseUrl}${url}`;
// Allows callback URLs on the same origin
else if (new URL(url).origin === baseUrl) return url;
return baseUrl + "/dashboard";
},
},
pages: {
signIn: "/auth/signin",
},
} satisfies NextAuthConfig;

View File

@@ -1,10 +0,0 @@
import NextAuth from "next-auth";
import { cache } from "react";
import { authConfig } from "./config";
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
const auth = cache(uncachedAuth);
export { auth, handlers, signIn, signOut };

View File

@@ -1,6 +1,6 @@
import { relations, sql } from "drizzle-orm";
import { index, primaryKey, pgTableCreator } from "drizzle-orm/pg-core";
import { type AdapterAccount } from "next-auth/adapters";
import { index, pgTableCreator } from "drizzle-orm/pg-core";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
@@ -17,14 +17,16 @@ export const users = createTable("user", (d) => ({
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: d.varchar({ length: 255 }),
email: d.varchar({ length: 255 }).notNull(),
password: d.varchar({ length: 255 }),
emailVerified: d.timestamp().default(sql`CURRENT_TIMESTAMP`),
name: d.varchar({ length: 255 }).notNull(),
email: d.varchar({ length: 255 }).notNull().unique(),
emailVerified: d.boolean().default(false).notNull(),
image: d.varchar({ length: 255 }),
resetToken: d.varchar({ length: 255 }),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
password: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetToken: d.varchar({ length: 255 }), // Matched DB: varchar(255)
resetTokenExpiry: d.timestamp(),
// User UI/animation preferences
// Custom fields
prefersReducedMotion: d.boolean().default(false).notNull(),
animationSpeedMultiplier: d.real().default(1).notNull(),
}));
@@ -34,31 +36,31 @@ export const usersRelations = relations(users, ({ many }) => ({
clients: many(clients),
businesses: many(businesses),
invoices: many(invoices),
sessions: many(sessions), // Added missing relation
}));
export const accounts = createTable(
"account",
(d) => ({
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
userId: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
provider: d.varchar({ length: 255 }).notNull(),
providerAccountId: d.varchar({ length: 255 }).notNull(),
refresh_token: d.text(),
access_token: d.text(),
expires_at: d.integer(),
token_type: d.varchar({ length: 255 }),
accountId: d.varchar({ length: 255 }).notNull(),
providerId: d.varchar({ length: 255 }).notNull(),
accessToken: d.text(),
refreshToken: d.text(),
accessTokenExpiresAt: d.timestamp(),
refreshTokenExpiresAt: d.timestamp(),
scope: d.varchar({ length: 255 }),
id_token: d.text(),
session_state: d.varchar({ length: 255 }),
idToken: d.text(),
password: d.text(), // Matched DB: text
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
}),
(t) => [
primaryKey({
columns: [t.provider, t.providerAccountId],
}),
index("account_user_id_idx").on(t.userId),
index("account_userId_idx").on(t.userId),
],
);
@@ -69,12 +71,17 @@ export const accountsRelations = relations(accounts, ({ one }) => ({
export const sessions = createTable(
"session",
(d) => ({
sessionToken: d.varchar({ length: 255 }).notNull().primaryKey(),
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
userId: d
.varchar({ length: 255 })
.notNull()
.references(() => users.id),
expires: d.timestamp().notNull(),
token: d.varchar({ length: 255 }).notNull().unique(),
expiresAt: d.timestamp().notNull(),
ipAddress: d.text(), // Matched DB: text
userAgent: d.text(), // Matched DB: text
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
}),
(t) => [index("session_userId_idx").on(t.userId)],
);
@@ -86,11 +93,14 @@ export const sessionsRelations = relations(sessions, ({ one }) => ({
export const verificationTokens = createTable(
"verification_token",
(d) => ({
id: d.text().notNull().primaryKey().$defaultFn(() => crypto.randomUUID()), // Matched DB: text
identifier: d.varchar({ length: 255 }).notNull(),
token: d.varchar({ length: 255 }).notNull(),
expires: d.timestamp().notNull(),
value: d.varchar({ length: 255 }).notNull(),
expiresAt: d.timestamp().notNull(),
createdAt: d.timestamp().notNull().defaultNow(),
updatedAt: d.timestamp().notNull().defaultNow().$onUpdate(() => new Date()),
}),
(t) => [primaryKey({ columns: [t.identifier, t.token] })],
(t) => [index("verification_token_identifier_idx").on(t.identifier)],
);
// Invoicing app tables

View File

@@ -6,7 +6,6 @@ import { createTRPCReact } from "@trpc/react-query";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";
import { SessionProvider } from "next-auth/react";
import { type AppRouter } from "~/server/api/root";
import { createQueryClient } from "./query-client";
@@ -64,13 +63,11 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
);
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
</SessionProvider>
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}