Compare commits

..

3 Commits

Author SHA1 Message Date
067710f1ae Merge pull request 'feat/add-better-auth' (#2) from feat/add-better-auth into main
Reviewed-on: #2
2026-05-29 19:08:20 +00:00
Jose Selesan
9482fea7f2 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.
2026-05-29 16:03:38 -03:00
Jose Selesan
de57ea4bd7 Refactor code structure for improved readability and maintainability 2026-05-29 15:55:59 -03:00
33 changed files with 621 additions and 19655 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ node_modules
.env.local .env.local
dist dist
build build
.vite

View File

@@ -11,6 +11,7 @@
"prisma:migrate:deploy": "prisma migrate deploy" "prisma:migrate:deploy": "prisma migrate deploy"
}, },
"dependencies": { "dependencies": {
"@noble/ciphers": "^2.1.1",
"@personal-admin/common": "workspace:*", "@personal-admin/common": "workspace:*",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",

View File

@@ -0,0 +1,78 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "session"("userId");
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "account"("userId");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,8 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client { generator client {
provider = "prisma-client" provider = "prisma-client"
output = "../src/generated/prisma" output = "../src/generated/prisma"
@@ -12,6 +7,68 @@ datasource db {
provider = "postgresql" provider = "postgresql"
} }
model User {
id String @id
name String
email String @unique
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("session")
}
model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@map("verification")
}
enum QuoteType { enum QuoteType {
BLUE BLUE
BNA BNA

View File

@@ -0,0 +1,58 @@
import "dotenv/config";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { PrismaClient } from "../src/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter });
async function main() {
const existingUser = await prisma.user.findUnique({
where: { email: "jselesan@gmail.com" },
});
if (existingUser) {
console.log("Admin user already exists, skipping seed.");
return;
}
const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
disableSignUp: false,
minPasswordLength: 4,
},
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
});
const password = process.env.ADMIN_PASSWORD;
if (!password) {
console.error("ADMIN_PASSWORD environment variable is not set.");
process.exit(1);
}
await auth.api.signUpEmail({
body: {
email: "jselesan@gmail.com",
password,
name: "Admin",
},
});
console.log("Admin user created successfully.");
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -1,5 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { auth } from "./lib/auth";
import { authMiddleware } from "./lib/auth-middleware";
import { startQuoteJob } from "./modules/quotes/quote.job"; import { startQuoteJob } from "./modules/quotes/quote.job";
import quotesRouter from "./modules/quotes/quotes.routes"; import quotesRouter from "./modules/quotes/quotes.routes";
import expensesRouter from "./modules/expenses/expenses.routes"; import expensesRouter from "./modules/expenses/expenses.routes";
@@ -12,6 +14,15 @@ app.get("/api/health", (c) => {
return c.json({ status: "ok", timestamp: new Date().toISOString() }); return c.json({ status: "ok", timestamp: new Date().toISOString() });
}); });
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
app.use("/api/*", async (c, next) => {
if (c.req.path === "/api/health" || c.req.path.startsWith("/api/auth")) {
return next();
}
return authMiddleware(c, next);
});
app.route("/api/quotes", quotesRouter); app.route("/api/quotes", quotesRouter);
app.route("/api", expensesRouter); app.route("/api", expensesRouter);

View File

@@ -0,0 +1,14 @@
import type { Context, Next } from "hono";
import { auth } from "./auth";
export async function authMiddleware(c: Context, next: Next) {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
c.set("user", session.user);
c.set("session", session.session);
await next();
}

View File

@@ -0,0 +1,20 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { prisma } from "./prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
disableSignUp: true,
minPasswordLength: 4,
},
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL!,
trustedOrigins: [
process.env.BETTER_AUTH_URL!,
"http://localhost:5173",
],
});

View File

@@ -4,6 +4,7 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"skipLibCheck": true,
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src"
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -1,208 +0,0 @@
{
"hash": "3d534d7c",
"configHash": "c0c89d92",
"lockfileHash": "e6e58bf9",
"browserHash": "5ec6480f",
"optimized": {
"react": {
"src": "../../../../../node_modules/react/index.js",
"file": "react.js",
"fileHash": "fcd31548",
"needsInterop": true
},
"react-dom": {
"src": "../../../../../node_modules/react-dom/index.js",
"file": "react-dom.js",
"fileHash": "ab67f690",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../../../../node_modules/react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "138cab66",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../../../../node_modules/react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "9805fa83",
"needsInterop": true
},
"@hookform/resolvers/zod": {
"src": "../../../../../node_modules/@hookform/resolvers/zod/dist/zod.mjs",
"file": "@hookform_resolvers_zod.js",
"fileHash": "194b2aaa",
"needsInterop": false
},
"@radix-ui/react-dialog": {
"src": "../../../../../node_modules/@radix-ui/react-dialog/dist/index.mjs",
"file": "@radix-ui_react-dialog.js",
"fileHash": "a148c7fe",
"needsInterop": false
},
"@tanstack/react-query": {
"src": "../../../../../node_modules/@tanstack/react-query/build/modern/index.js",
"file": "@tanstack_react-query.js",
"fileHash": "ffafe25c",
"needsInterop": false
},
"@tanstack/react-query-devtools": {
"src": "../../../../../node_modules/@tanstack/react-query-devtools/build/modern/index.js",
"file": "@tanstack_react-query-devtools.js",
"fileHash": "f836306e",
"needsInterop": false
},
"@tanstack/react-router": {
"src": "../../../../../node_modules/@tanstack/react-router/dist/esm/index.dev.js",
"file": "@tanstack_react-router.js",
"fileHash": "5e18d809",
"needsInterop": false
},
"@tanstack/react-table": {
"src": "../../../../../node_modules/@tanstack/react-table/build/lib/index.mjs",
"file": "@tanstack_react-table.js",
"fileHash": "de1dde72",
"needsInterop": false
},
"class-variance-authority": {
"src": "../../../../../node_modules/class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js",
"fileHash": "79ca80ed",
"needsInterop": false
},
"clsx": {
"src": "../../../../../node_modules/clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "560d7dff",
"needsInterop": false
},
"lucide-react": {
"src": "../../../../../node_modules/lucide-react/dist/esm/lucide-react.mjs",
"file": "lucide-react.js",
"fileHash": "27bd6cb0",
"needsInterop": false
},
"radix-ui": {
"src": "../../../../../node_modules/radix-ui/dist/index.mjs",
"file": "radix-ui.js",
"fileHash": "f216ffc2",
"needsInterop": false
},
"react-dom/client": {
"src": "../../../../../node_modules/react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "a87aab08",
"needsInterop": true
},
"react-hook-form": {
"src": "../../../../../node_modules/react-hook-form/dist/index.esm.mjs",
"file": "react-hook-form.js",
"fileHash": "72222317",
"needsInterop": false
},
"recharts": {
"src": "../../../../../node_modules/recharts/es6/index.js",
"file": "recharts.js",
"fileHash": "de40db9e",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../../../../node_modules/tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "d68cea17",
"needsInterop": false
},
"zod": {
"src": "../../../../../node_modules/zod/index.js",
"file": "zod.js",
"fileHash": "c1ae98b2",
"needsInterop": false
},
"date-fns": {
"src": "../../../../../node_modules/date-fns/index.js",
"file": "date-fns.js",
"fileHash": "5586195a",
"needsInterop": false
},
"date-fns/locale": {
"src": "../../../../../node_modules/date-fns/locale.js",
"file": "date-fns_locale.js",
"fileHash": "78139fca",
"needsInterop": false
},
"@radix-ui/react-popover": {
"src": "../../../../../node_modules/@radix-ui/react-popover/dist/index.mjs",
"file": "@radix-ui_react-popover.js",
"fileHash": "1a4bc9a8",
"needsInterop": false
},
"react-day-picker": {
"src": "../../../../../node_modules/react-day-picker/dist/esm/index.js",
"file": "react-day-picker.js",
"fileHash": "a6f8a45a",
"needsInterop": false
}
},
"chunks": {
"ZUJJ2RGI-GFZTQM7R": {
"file": "ZUJJ2RGI-GFZTQM7R.js"
},
"VKXKX7EQ-FAVAKZBN": {
"file": "VKXKX7EQ-FAVAKZBN.js"
},
"chunk-LSVH5W4R": {
"file": "chunk-LSVH5W4R.js"
},
"chunk-G4FIB5Z7": {
"file": "chunk-G4FIB5Z7.js"
},
"chunk-6US6M7KI": {
"file": "chunk-6US6M7KI.js"
},
"chunk-TLZOGT6B": {
"file": "chunk-TLZOGT6B.js"
},
"chunk-O6J3DKHL": {
"file": "chunk-O6J3DKHL.js"
},
"chunk-A2TD7EYA": {
"file": "chunk-A2TD7EYA.js"
},
"chunk-NMJBVCD2": {
"file": "chunk-NMJBVCD2.js"
},
"chunk-X37QSMNJ": {
"file": "chunk-X37QSMNJ.js"
},
"chunk-WFNHCR67": {
"file": "chunk-WFNHCR67.js"
},
"chunk-R6CVIFUM": {
"file": "chunk-R6CVIFUM.js"
},
"chunk-2EY3U74L": {
"file": "chunk-2EY3U74L.js"
},
"chunk-TYPHK3K7": {
"file": "chunk-TYPHK3K7.js"
},
"chunk-ITOVD2G7": {
"file": "chunk-ITOVD2G7.js"
},
"chunk-OAGFOHTQ": {
"file": "chunk-OAGFOHTQ.js"
},
"chunk-SOQCU2R6": {
"file": "chunk-SOQCU2R6.js"
},
"chunk-JCMTJHXH": {
"file": "chunk-JCMTJHXH.js"
},
"chunk-6VO5C3OI": {
"file": "chunk-6VO5C3OI.js"
},
"chunk-WOOG5QLI": {
"file": "chunk-WOOG5QLI.js"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,22 @@
import { RouterProvider } from "@tanstack/react-router"; import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/router"; import { router } from "@/router";
import { SseProvider } from "@/lib/sse-context"; import { SseProvider } from "@/lib/sse-context";
import { useAuth } from "@/lib/auth-context";
function App() { 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 ( return (
<SseProvider> <SseProvider>
<RouterProvider router={router} /> <RouterProvider router={router} context={{ auth }} />
</SseProvider> </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 { Button } from "@/components/ui/button";
import { useSseStatus } from "@/lib/sse-context"; import { useSseStatus } from "@/lib/sse-context";
import { useTheme } from "@/lib/theme"; import { useTheme } from "@/lib/theme";
import { useAuth } from "@/lib/auth-context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface HeaderProps { interface HeaderProps {
@@ -11,11 +12,17 @@ interface HeaderProps {
export function Header({ onMenuClick }: HeaderProps) { export function Header({ onMenuClick }: HeaderProps) {
const sseStatus = useSseStatus(); const sseStatus = useSseStatus();
const { theme, cycleTheme } = useTheme(); const { theme, cycleTheme } = useTheme();
const auth = useAuth();
const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor; const ThemeIcon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor;
const themeLabel = const themeLabel =
theme === "light" ? "Claro" : theme === "dark" ? "Oscuro" : "Sistema"; theme === "light" ? "Claro" : theme === "dark" ? "Oscuro" : "Sistema";
async function handleSignOut() {
await auth.signOut();
window.location.href = "/login";
}
return ( return (
<header className="flex h-14 items-center gap-4 border-b px-4"> <header className="flex h-14 items-center gap-4 border-b px-4">
<Button <Button
@@ -28,6 +35,21 @@ export function Header({ onMenuClick }: HeaderProps) {
</Button> </Button>
<div className="flex-1" /> <div className="flex-1" />
<div className="flex items-center gap-1.5"> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router"; 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"; import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
@@ -9,6 +9,10 @@ const navItems = [
{ to: "/quotes/belo", label: "BELO", icon: TrendingUp }, { to: "/quotes/belo", label: "BELO", icon: TrendingUp },
]; ];
const bottomItems = [
{ to: "/settings", label: "Configuración", icon: Settings },
];
interface SidebarProps { interface SidebarProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -48,6 +52,20 @@ export function Sidebar({ open, onClose }: SidebarProps) {
</Link> </Link>
))} ))}
</nav> </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> </aside>
</> </>
); );

View File

@@ -39,7 +39,7 @@ export type DailyQuote = {
}; };
async function fetcher<T>(url: string): Promise<T> { async function fetcher<T>(url: string): Promise<T> {
const res = await fetch(url); const res = await fetch(url, { credentials: "include" });
if (!res.ok) { if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`); 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, method,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`); 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", { return fetch("/api/expenses/import", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}).then((r) => { }).then((r) => {
if (!r.ok) throw new Error(`API error: ${r.status} ${r.statusText}`); if (!r.ok) throw new Error(`API error: ${r.status} ${r.statusText}`);
return r.json(); return r.json();
@@ -247,6 +249,7 @@ export function importPeriodicExpenses(file: File): Promise<ImportResult> {
return fetch("/api/periodic-expenses/import", { return fetch("/api/periodic-expenses/import", {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "include",
}).then((r) => { }).then((r) => {
if (!r.ok) throw new Error(`API error: ${r.status} ${r.statusText}`); if (!r.ok) throw new Error(`API error: ${r.status} ${r.statusText}`);
return r.json(); 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ThemeProvider } from "@/lib/theme"; import { ThemeProvider } from "@/lib/theme";
import { AuthProvider } from "@/lib/auth-context";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
@@ -20,7 +21,9 @@ createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider>
<App /> <App />
</AuthProvider>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider> </ThemeProvider>

View File

@@ -1,7 +1,21 @@
import { createRouter } from "@tanstack/react-router"; import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen"; 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" { declare module "@tanstack/react-router" {
interface Register { interface Register {

View File

@@ -1,6 +1,14 @@
import { createRootRoute } from "@tanstack/react-router"; import { createRootRouteWithContext, Outlet, redirect } from "@tanstack/react-router";
import { Layout } from "@/components/layout/Layout"; import type { RouterContext } from "@/router";
export const Route = createRootRoute({ export const Route = createRootRouteWithContext<RouterContext>()({
component: Layout, 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 { createFileRoute } from "@tanstack/react-router";
import { ExpensesPage } from "@/features/expenses/ExpensesPage"; import { ExpensesPage } from "@/features/expenses/ExpensesPage";
export const Route = createFileRoute("/expenses")({ export const Route = createFileRoute("/_authenticated/expenses")({
component: ExpensesPage, component: ExpensesPage,
}); });

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"skipLibCheck": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"outDir": "dist", "outDir": "dist",
"rootDir": "src", "rootDir": "src",

BIN
bun.lockb

Binary file not shown.

View File

@@ -2,7 +2,7 @@
"name": "personal-admin", "name": "personal-admin",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "concurrently -n backend,frontend -c blue,green \"bun run --cwd apps/backend dev\" \"bun run --cwd apps/frontend dev\"", "dev": "conc -n backend,frontend -c blue,green \"bun run --cwd apps/backend dev\" \"bun run --cwd apps/frontend dev\"",
"build": "bun run --cwd apps/frontend build" "build": "bun run --cwd apps/frontend build"
}, },
"workspaces": [ "workspaces": [
@@ -11,5 +11,8 @@
], ],
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.0" "concurrently": "^9.1.0"
},
"dependencies": {
"@noble/ciphers": "^2.2.0"
} }
} }