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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
5
apps/frontend/src/lib/auth-client.ts
Normal file
5
apps/frontend/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
|
||||
export type AuthSession = typeof authClient.$Infer.Session;
|
||||
63
apps/frontend/src/lib/auth-context.tsx
Normal file
63
apps/frontend/src/lib/auth-context.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
6
apps/frontend/src/routes/_authenticated.tsx
Normal file
6
apps/frontend/src/routes/_authenticated.tsx
Normal 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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/quotes")({
|
||||
export const Route = createFileRoute("/_authenticated/quotes")({
|
||||
component: QuotesLayout,
|
||||
});
|
||||
|
||||
107
apps/frontend/src/routes/_authenticated/settings.tsx
Normal file
107
apps/frontend/src/routes/_authenticated/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/frontend/src/routes/login.tsx
Normal file
95
apps/frontend/src/routes/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user