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

@@ -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>