Compare commits
3 Commits
64ef4440f6
...
067710f1ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 067710f1ae | |||
|
|
9482fea7f2 | ||
|
|
de57ea4bd7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ node_modules
|
|||||||
.env.local
|
.env.local
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
|
.vite
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
58
apps/backend/prisma/seed.ts
Normal file
58
apps/backend/prisma/seed.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
14
apps/backend/src/lib/auth-middleware.ts
Normal file
14
apps/backend/src/lib/auth-middleware.ts
Normal 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();
|
||||||
|
}
|
||||||
20
apps/backend/src/lib/auth.ts
Normal file
20
apps/backend/src/lib/auth.ts
Normal 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",
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
8362
apps/frontend/node_modules/.vite/deps/@tanstack_react-router.js
generated
vendored
8362
apps/frontend/node_modules/.vite/deps/@tanstack_react-router.js
generated
vendored
File diff suppressed because it is too large
Load Diff
208
apps/frontend/node_modules/.vite/deps/_metadata.json
generated
vendored
208
apps/frontend/node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11057
apps/frontend/node_modules/.vite/deps/radix-ui.js
generated
vendored
11057
apps/frontend/node_modules/.vite/deps/radix-ui.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
apps/frontend/node_modules/.vite/deps/radix-ui.js.map
generated
vendored
7
apps/frontend/node_modules/.vite/deps/radix-ui.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
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 { 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}>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
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 { 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,
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user