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 (