diff --git a/.gitignore b/.gitignore index f1944f3..dc6c862 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules .env.local dist build +.vite diff --git a/apps/backend/package.json b/apps/backend/package.json index db9414c..bc76d55 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,6 +11,7 @@ "prisma:migrate:deploy": "prisma migrate deploy" }, "dependencies": { + "@noble/ciphers": "^2.1.1", "@personal-admin/common": "workspace:*", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", diff --git a/apps/backend/prisma/migrations/20260529183748_add_auth/migration.sql b/apps/backend/prisma/migrations/20260529183748_add_auth/migration.sql new file mode 100644 index 0000000..a154b00 --- /dev/null +++ b/apps/backend/prisma/migrations/20260529183748_add_auth/migration.sql @@ -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; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 13a0956..961c15f 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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 { provider = "prisma-client" output = "../src/generated/prisma" @@ -12,6 +7,68 @@ datasource db { 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 { BLUE BNA diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts new file mode 100644 index 0000000..ca721ff --- /dev/null +++ b/apps/backend/prisma/seed.ts @@ -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(); + }); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 0a2c528..0dd8722 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,5 +1,7 @@ import { Hono } from "hono"; import { serveStatic } from "hono/bun"; +import { auth } from "./lib/auth"; +import { authMiddleware } from "./lib/auth-middleware"; import { startQuoteJob } from "./modules/quotes/quote.job"; import quotesRouter from "./modules/quotes/quotes.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() }); }); +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", expensesRouter); diff --git a/apps/backend/src/lib/auth-middleware.ts b/apps/backend/src/lib/auth-middleware.ts new file mode 100644 index 0000000..7ab870a --- /dev/null +++ b/apps/backend/src/lib/auth-middleware.ts @@ -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(); +} diff --git a/apps/backend/src/lib/auth.ts b/apps/backend/src/lib/auth.ts new file mode 100644 index 0000000..fb530dc --- /dev/null +++ b/apps/backend/src/lib/auth.ts @@ -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", + ], +}); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index a0fd9e8..c0c91eb 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "strict": true, + "skipLibCheck": true, "outDir": "dist", "rootDir": "src" }, diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 5b434f0..a540c2d 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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 ( +
+
+
+ ); + } + return ( - + ); } diff --git a/apps/frontend/src/components/layout/Header.tsx b/apps/frontend/src/components/layout/Header.tsx index d04455e..8bd5270 100644 --- a/apps/frontend/src/components/layout/Header.tsx +++ b/apps/frontend/src/components/layout/Header.tsx @@ -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 (
+ + )} + +
+ ); +} diff --git a/apps/frontend/src/routes/login.tsx b/apps/frontend/src/routes/login.tsx new file mode 100644 index 0000000..7b2bf2c --- /dev/null +++ b/apps/frontend/src/routes/login.tsx @@ -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(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 ( +
+
+
+
+
+ +
+
+

+ Personal Admin +

+

+ Ingresá tu contraseña para continuar +

+
+
+
+
+ + +
+
+ + setPassword(e.target.value)} + autoFocus + className="text-base" + /> +
+ {error && ( +

{error}

+ )} + +
+
+

+ App privada — acceso restringido +

+
+
+ ); +} diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index c200fd9..c904f10 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "strict": true, + "skipLibCheck": true, "jsx": "react-jsx", "outDir": "dist", "rootDir": "src", diff --git a/bun.lockb b/bun.lockb index faaea3a..5288251 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index bc649b6..d86f473 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "personal-admin", "private": true, "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" }, "workspaces": [ @@ -11,5 +11,8 @@ ], "devDependencies": { "concurrently": "^9.1.0" + }, + "dependencies": { + "@noble/ciphers": "^2.2.0" } }