feat/add-better-auth #2

Merged
jselesan merged 2 commits from feat/add-better-auth into main 2026-05-29 19:08:21 +00:00
33 changed files with 621 additions and 19655 deletions
Showing only changes of commit 9482fea7f2 - Show all commits

1
.gitignore vendored
View File

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

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

View File

@@ -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 (
<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 (
<SseProvider>
<RouterProvider router={router} />
<RouterProvider router={router} context={{ auth }} />
</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 { 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 (
<header className="flex h-14 items-center gap-4 border-b px-4">
<Button
@@ -28,6 +35,21 @@ export function Header({ onMenuClick }: HeaderProps) {
</Button>
<div className="flex-1" />
<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
variant="ghost"
size="icon"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/quotes")({
export const Route = createFileRoute("/_authenticated/quotes")({
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",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"outDir": "dist",
"rootDir": "src",

BIN
bun.lockb

Binary file not shown.

View File

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