feat(auth): implement better authentication system with user sessions and password management

- Added user, session, account, and verification models to Prisma schema.
- Integrated better-auth for user authentication with email and password.
- Created auth middleware for session validation.
- Implemented auth context and client in frontend for managing user sessions.
- Added login and settings pages for user authentication and password management.
- Updated routes to include authentication checks and user-specific content.
- Enhanced UI components to reflect authentication state.
This commit is contained in:
Jose Selesan
2026-05-29 16:03:38 -03:00
parent de57ea4bd7
commit 9482fea7f2
29 changed files with 621 additions and 21 deletions

View File

@@ -1,11 +1,22 @@
import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/router";
import { SseProvider } from "@/lib/sse-context";
import { useAuth } from "@/lib/auth-context";
function App() {
const auth = useAuth();
if (auth.isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="size-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
);
}
return (
<SseProvider>
<RouterProvider router={router} />
<RouterProvider router={router} context={{ auth }} />
</SseProvider>
);
}

View File

@@ -1,7 +1,8 @@
import { Menu, Sun, Moon, Monitor } from "lucide-react";
import { Menu, Sun, Moon, Monitor, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useSseStatus } from "@/lib/sse-context";
import { useTheme } from "@/lib/theme";
import { useAuth } from "@/lib/auth-context";
import { cn } from "@/lib/utils";
interface HeaderProps {
@@ -11,11 +12,17 @@ interface HeaderProps {
export function Header({ onMenuClick }: HeaderProps) {
const sseStatus = useSseStatus();
const { theme, cycleTheme } = useTheme();
const auth = useAuth();
const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
const themeLabel =
theme === "light" ? "Claro" : theme === "dark" ? "Oscuro" : "Sistema";
async function handleSignOut() {
await auth.signOut();
window.location.href = "/login";
}
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<Button
@@ -28,6 +35,21 @@ export function Header({ onMenuClick }: HeaderProps) {
</Button>
<div className="flex-1" />
<div className="flex items-center gap-1.5">
{auth.isAuthenticated && (
<>
<span className="hidden text-sm text-muted-foreground sm:inline">
{auth.session?.user.email}
</span>
<Button
variant="ghost"
size="icon"
onClick={handleSignOut}
title="Cerrar sesión"
>
<LogOut className="size-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"

View File

@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { FileText, LayoutDashboard, TrendingUp, Wallet } from "lucide-react";
import { FileText, LayoutDashboard, Settings, TrendingUp, Wallet } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
@@ -9,6 +9,10 @@ const navItems = [
{ to: "/quotes/belo", label: "BELO", icon: TrendingUp },
];
const bottomItems = [
{ to: "/settings", label: "Configuración", icon: Settings },
];
interface SidebarProps {
open: boolean;
onClose: () => void;
@@ -48,6 +52,20 @@ export function Sidebar({ open, onClose }: SidebarProps) {
</Link>
))}
</nav>
<div className="border-t p-3">
{bottomItems.map((item) => (
<Link
key={item.to}
to={item.to}
activeProps={{ className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium" }}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={onClose}
>
<item.icon className="size-4" />
{item.label}
</Link>
))}
</div>
</aside>
</>
);

View File

@@ -39,7 +39,7 @@ export type DailyQuote = {
};
async function fetcher<T>(url: string): Promise<T> {
const res = await fetch(url);
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
}
@@ -51,6 +51,7 @@ async function mutator<T>(url: string, method: string, body?: unknown): Promise<
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
credentials: "include",
});
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`);
@@ -235,6 +236,7 @@ export function importExpenses(file: File): Promise<ImportExpensesResult> {
return fetch("/api/expenses/import", {
method: "POST",
body: formData,
credentials: "include",
}).then((r) => {
if (!r.ok) throw new Error(`API error: ${r.status} ${r.statusText}`);
return r.json();
@@ -247,6 +249,7 @@ export function importPeriodicExpenses(file: File): Promise<ImportResult> {
return fetch("/api/periodic-expenses/import", {
method: "POST",
body: formData,
credentials: "include",
}).then((r) => {
if (!r.ok) throw new Error(`API error: ${r.status} ${r.statusText}`);
return r.json();

View File

@@ -0,0 +1,5 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
export type AuthSession = typeof authClient.$Infer.Session;

View File

@@ -0,0 +1,63 @@
import { createContext, useContext, useCallback, type ReactNode } from "react";
import { authClient, type AuthSession } from "./auth-client";
interface AuthContextValue {
session: AuthSession | null;
isPending: boolean;
isAuthenticated: boolean;
signIn: (password: string) => Promise<void>;
signOut: () => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const { data: session, isPending } = authClient.useSession();
const signIn = useCallback(async (password: string) => {
const { error } = await authClient.signIn.email({
email: "jselesan@gmail.com",
password,
});
if (error) throw error;
}, []);
const signOut = useCallback(async () => {
await authClient.signOut();
}, []);
const changePassword = useCallback(
async (currentPassword: string, newPassword: string) => {
const { error } = await authClient.changePassword({
currentPassword,
newPassword,
});
if (error) throw error;
},
[],
);
return (
<AuthContext.Provider
value={{
session: session ?? null,
isPending,
isAuthenticated: !!session,
signIn,
signOut,
changePassword,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}

View File

@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ThemeProvider } from "@/lib/theme";
import { AuthProvider } from "@/lib/auth-context";
import App from "./App";
import "./index.css";
@@ -20,7 +21,9 @@ createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<App />
<AuthProvider>
<App />
</AuthProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ThemeProvider>

View File

@@ -1,7 +1,21 @@
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
const router = createRouter({ routeTree });
export interface RouterContext {
auth: {
session: object | null;
isPending: boolean;
isAuthenticated: boolean;
signIn: (password: string) => Promise<void>;
signOut: () => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
};
}
const router = createRouter({
routeTree,
context: {} as RouterContext,
});
declare module "@tanstack/react-router" {
interface Register {

View File

@@ -1,6 +1,14 @@
import { createRootRoute } from "@tanstack/react-router";
import { Layout } from "@/components/layout/Layout";
import { createRootRouteWithContext, Outlet, redirect } from "@tanstack/react-router";
import type { RouterContext } from "@/router";
export const Route = createRootRoute({
component: Layout,
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: async ({ context, location }) => {
if (!context.auth.isAuthenticated && location.pathname !== "/login") {
throw redirect({ to: "/login" });
}
if (context.auth.isAuthenticated && location.pathname === "/login") {
throw redirect({ to: "/" });
}
},
component: Outlet,
});

View File

@@ -0,0 +1,6 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { Layout } from "@/components/layout/Layout";
export const Route = createFileRoute("/_authenticated")({
component: Layout,
});

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { ExpensesPage } from "@/features/expenses/ExpensesPage";
export const Route = createFileRoute("/expenses")({
export const Route = createFileRoute("/_authenticated/expenses")({
component: ExpensesPage,
});

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { DashboardPage } from "@/features/dashboard/DashboardPage";
export const Route = createFileRoute("/")({
export const Route = createFileRoute("/_authenticated/")({
component: DashboardPage,
});

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { BeloPage } from "@/features/belo/BeloPage";
export const Route = createFileRoute("/quotes/belo")({
export const Route = createFileRoute("/_authenticated/quotes/belo")({
component: BeloPage,
});

View File

@@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { QuotesPage } from "@/features/quotes/QuotesPage";
export const Route = createFileRoute("/quotes/")({
export const Route = createFileRoute("/_authenticated/quotes/")({
component: QuotesPage,
});

View File

@@ -1,6 +1,6 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/quotes")({
export const Route = createFileRoute("/_authenticated/quotes")({
component: QuotesLayout,
});

View File

@@ -0,0 +1,107 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context";
export const Route = createFileRoute("/_authenticated/settings")({
component: SettingsPage,
});
function SettingsPage() {
const auth = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSuccess(false);
if (newPassword !== confirmPassword) {
setError("Las contraseñas no coinciden");
return;
}
setLoading(true);
try {
await auth.changePassword(currentPassword, newPassword);
setSuccess(true);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch (err: unknown) {
const message =
err instanceof Error
? err.message
: typeof err === "object" && err && "message" in err
? String((err as { message: unknown }).message)
: "Error al cambiar la contraseña";
setError(message);
} finally {
setLoading(false);
}
}
return (
<div className="mx-auto max-w-md space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Configuración</h1>
<p className="text-sm text-muted-foreground">
Cambiar contraseña
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Email</label>
<Input
value={auth.session?.user.email ?? ""}
readOnly
className="bg-muted"
tabIndex={-1}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Contraseña actual</label>
<Input
type="password"
placeholder="••••••••"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Nueva contraseña</label>
<Input
type="password"
placeholder="••••••••"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Confirmar nueva contraseña</label>
<Input
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{success && (
<p className="text-sm text-emerald-600">Contraseña actualizada correctamente</p>
)}
<Button type="submit" className="w-full" disabled={loading || !currentPassword || !newPassword || !confirmPassword}>
{loading ? "Guardando..." : "Cambiar contraseña"}
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context";
export const Route = createFileRoute("/login")({
component: LoginPage,
});
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
await auth.signIn(password);
navigate({ to: "/" });
} catch (err: unknown) {
const message =
err instanceof Error
? err.message
: typeof err === "object" && err && "message" in err
? String((err as { message: unknown }).message)
: "Error al iniciar sesión";
setError(message);
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/50 p-4">
<div className="w-full max-w-sm">
<div className="rounded-xl border bg-card p-8 shadow-lg">
<div className="mb-8 flex flex-col items-center gap-3">
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
<Lock className="size-6 text-primary" />
</div>
<div className="text-center">
<h1 className="text-xl font-semibold tracking-tight">
Personal Admin
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Ingresá tu contraseña para continuar
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Email</label>
<Input
value="jselesan@gmail.com"
readOnly
className="bg-muted text-muted-foreground"
tabIndex={-1}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Contraseña</label>
<Input
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
className="text-base"
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button
type="submit"
className="w-full"
disabled={loading || !password}
>
{loading ? "Ingresando..." : "Ingresar"}
</Button>
</form>
</div>
<p className="mt-4 text-center text-xs text-muted-foreground">
App privada acceso restringido
</p>
</div>
</div>
);
}