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

@@ -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",

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 {
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

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 { 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);

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",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},