Login sequence and inventory/protocol storage groundwork

This commit is contained in:
2026-03-19 05:42:11 +00:00
parent 5b2c7e4506
commit 55bbd6909d
21 changed files with 3882 additions and 157 deletions

60
App.tsx
View File

@@ -2,16 +2,21 @@ import { useState } from "react";
import { Dashboard } from "./components/Dashboard";
import { Inventory } from "./components/Inventory";
import { ProtocolChecker } from "./components/ProtocolChecker";
import { useSession, signIn, signOut } from "./lib/auth-client";
import { Button } from "./components/ui/button";
import { Card, CardContent } from "./components/ui/card";
import {
LayoutDashboard,
Package,
FileCheck
FileCheck,
LogOut,
} from "lucide-react";
const logo = "/logo.png";
type Tab = "dashboard" | "inventory" | "protocol";
export default function App() {
const { data: session, isPending } = useSession();
const [activeTab, setActiveTab] = useState<Tab>("dashboard");
const navItems = [
@@ -20,6 +25,46 @@ export default function App() {
{ id: "protocol" as Tab, label: "Protocol Checker", icon: FileCheck },
];
if (isPending) {
return (
<div className="flex h-screen items-center justify-center bg-secondary">
<img src={logo} alt="LabWise" className="h-12 opacity-50 animate-pulse" />
</div>
);
}
if (!session) {
return (
<div className="flex h-screen items-center justify-center bg-secondary">
<Card className="w-full max-w-sm">
<CardContent className="p-8 space-y-6">
<div className="flex justify-center">
<img src={logo} alt="LabWise" className="h-12" />
</div>
<p className="text-center text-muted-foreground text-sm">
Sign in to access your lab inventory and protocols.
</p>
<div className="space-y-3">
<Button
className="w-full"
onClick={() => signIn.social({ provider: "google", callbackURL: "/" })}
>
Sign in with Google
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => signIn.social({ provider: "github", callbackURL: "/" })}
>
Sign in with GitHub
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex h-screen bg-secondary">
{/* Sidebar */}
@@ -47,6 +92,19 @@ export default function App() {
);
})}
</nav>
<div className="p-4 border-t border-border">
<div className="text-xs text-muted-foreground mb-2 truncate px-1">
{session.user.email}
</div>
<button
onClick={() => signOut()}
className="w-full flex items-center gap-3 px-4 py-2 rounded-lg text-muted-foreground hover:bg-muted transition-colors text-sm"
>
<LogOut className="w-4 h-4" />
<span>Sign out</span>
</button>
</div>
</aside>
{/* Main Content */}

View File

@@ -1,4 +1,6 @@
import { useState, useRef } from "react";
import { useState, useRef, useEffect } from "react";
import { chemicalsApi } from "../lib/api";
import type { ChemicalInventory } from "../shared/types";
import { Card } from "./ui/card";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -34,47 +36,6 @@ import {
SelectValue,
} from "./ui/select";
interface ChemicalInventory {
id: string;
// Required fields (red)
piFirstName: string;
physicalState: string;
chemicalName: string;
bldgCode: string;
lab: string;
storageLocation: string;
storageDevice: string;
numberOfContainers: string;
amountPerContainer: string;
unitOfMeasure: string;
casNumber: string;
// Nice to have fields
chemicalFormula?: string;
molecularWeight?: string;
vendor?: string;
catalogNumber?: string;
foundInCatalog?: string;
poNumber?: string;
receiptDate?: string;
openDate?: string;
maxOnHand?: string;
expirationDate?: string;
contact?: string;
comments?: string;
dateEntered?: string;
permitNumber?: string;
barcode?: string;
lastChanged?: string;
concentration?: string;
chemicalNumber?: string;
lotNumber?: string;
multipleCAS?: string;
msds?: string;
// Special fields
percentageFull?: number; // blue
needsManualEntry?: string[]; // yellow highlight
scannedImage?: string;
}
const storageDeviceOptions = [
"Aerosol Can",
@@ -102,71 +63,15 @@ export function Inventory() {
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [inventory, setInventory] = useState<ChemicalInventory[]>([
{
id: "1",
piFirstName: "Dr. Smith",
physicalState: "Liquid",
chemicalName: "Acetone",
bldgCode: "BLD-A",
lab: "Lab 201",
storageLocation: "Cabinet A-3",
storageDevice: "Glass Bottle",
numberOfContainers: "2",
amountPerContainer: "1",
unitOfMeasure: "L",
casNumber: "67-64-1",
chemicalFormula: "C3H6O",
molecularWeight: "58.08",
vendor: "Sigma-Aldrich",
catalogNumber: "179124",
expirationDate: "2025-06-15",
lotNumber: "SLCD1234",
percentageFull: 65,
dateEntered: "2024-11-15",
openDate: "2024-11-15"
},
{
id: "2",
piFirstName: "Dr. Johnson",
physicalState: "Solid",
chemicalName: "Sodium Hydroxide",
bldgCode: "BLD-B",
lab: "Lab 305",
storageLocation: "Cabinet B-1",
storageDevice: "Plastic Bottle",
numberOfContainers: "1",
amountPerContainer: "500",
unitOfMeasure: "g",
casNumber: "1310-73-2",
chemicalFormula: "NaOH",
molecularWeight: "39.997",
vendor: "Fisher Scientific",
expirationDate: "2025-10-22",
lotNumber: "FS9876",
percentageFull: 15,
dateEntered: "2024-10-22"
},
{
id: "3",
piFirstName: "Dr. Smith",
physicalState: "Liquid",
chemicalName: "Hydrochloric Acid",
bldgCode: "BLD-A",
lab: "Lab 201",
storageLocation: "Acid Cabinet",
storageDevice: "Glass Bottle",
numberOfContainers: "1",
amountPerContainer: "1",
unitOfMeasure: "L",
casNumber: "7647-01-0",
chemicalFormula: "HCl",
vendor: "Fisher Scientific",
expirationDate: "2024-12-10",
percentageFull: 80,
dateEntered: "2024-08-15"
}
]);
const [inventory, setInventory] = useState<ChemicalInventory[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
chemicalsApi.list()
.then(data => setInventory(data))
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
const handlePhotoCapture = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -211,10 +116,11 @@ export function Inventory() {
}, 2000);
};
const handleAddFromScan = () => {
const handleAddFromScan = async () => {
if (extractedData) {
const newChemical: ChemicalInventory = {
id: Date.now().toString(),
try {
const saved = await chemicalsApi.create({
...extractedData,
piFirstName: extractedData.piFirstName || "",
physicalState: extractedData.physicalState || "",
chemicalName: extractedData.chemicalName || "",
@@ -226,23 +132,14 @@ export function Inventory() {
amountPerContainer: extractedData.amountPerContainer || "",
unitOfMeasure: extractedData.unitOfMeasure || "",
casNumber: extractedData.casNumber || "",
chemicalFormula: extractedData.chemicalFormula,
molecularWeight: extractedData.molecularWeight,
vendor: extractedData.vendor,
catalogNumber: extractedData.catalogNumber,
expirationDate: extractedData.expirationDate,
lotNumber: extractedData.lotNumber,
concentration: extractedData.concentration,
percentageFull: extractedData.percentageFull,
dateEntered: new Date().toISOString().split('T')[0],
needsManualEntry: extractedData.needsManualEntry,
scannedImage: extractedData.scannedImage
};
setInventory([newChemical, ...inventory]);
});
setInventory([saved, ...inventory]);
setCapturedImage(null);
setExtractedData(null);
setIsPhotoDialogOpen(false);
} catch (err) {
console.error("Failed to save chemical:", err);
}
}
};
@@ -615,6 +512,9 @@ export function Inventory() {
</div>
{/* Spreadsheet Table */}
{isLoading && (
<p className="text-center text-muted-foreground py-8">Loading inventory...</p>
)}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { protocolsApi } from "../lib/api";
import { Card } from "./ui/card";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
@@ -18,13 +19,7 @@ import {
import { Alert, AlertDescription } from "./ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
interface SafetyIssue {
type: "critical" | "warning" | "suggestion";
category: string;
message: string;
source?: string;
sourceUrl?: string;
}
import type { SafetyIssue } from "../shared/types";
interface ChatMessage {
id: string;
@@ -85,12 +80,21 @@ export function ProtocolChecker() {
}
];
const handleAnalyze = () => {
const handleAnalyze = async () => {
if (!protocol.trim()) return;
setIsAnalyzing(true);
setTimeout(() => {
setIsAnalyzing(false);
try {
const saved = await protocolsApi.createFromText(
`Protocol ${new Date().toLocaleDateString()}`,
protocol
);
await protocolsApi.saveAnalysis(saved.id, mockIssues);
setAnalyzed(true);
}, 2000);
} catch (err) {
console.error("Failed to save protocol:", err);
} finally {
setIsAnalyzing(false);
}
};
const handleSendMessage = () => {

63
lib/api.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { ChemicalInventory, SafetyIssue } from '../shared/types';
async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> {
const res = await fetch(path, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || 'API error');
}
return res;
}
export const chemicalsApi = {
list: (): Promise<ChemicalInventory[]> =>
apiFetch('/api/chemicals').then(r => r.json()),
create: (data: Partial<ChemicalInventory>): Promise<ChemicalInventory> =>
apiFetch('/api/chemicals', {
method: 'POST',
body: JSON.stringify(data),
}).then(r => r.json()),
update: (id: string, data: Partial<ChemicalInventory>): Promise<ChemicalInventory> =>
apiFetch(`/api/chemicals/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}).then(r => r.json()),
remove: (id: string): Promise<void> =>
apiFetch(`/api/chemicals/${id}`, { method: 'DELETE' }).then(() => undefined),
};
export const protocolsApi = {
list: () => apiFetch('/api/protocols').then(r => r.json()),
create: (formData: FormData) =>
fetch('/api/protocols', {
method: 'POST',
credentials: 'include',
body: formData, // Don't set Content-Type — browser sets multipart boundary
}).then(r => r.json()),
createFromText: (title: string, content: string) =>
apiFetch('/api/protocols', {
method: 'POST',
body: JSON.stringify({ title, content }),
}).then(r => r.json()),
saveAnalysis: (id: string, results: SafetyIssue[]) =>
apiFetch(`/api/protocols/${id}/analysis`, {
method: 'PATCH',
body: JSON.stringify({ analysis_results: results }),
}).then(r => r.json()),
remove: (id: string): Promise<void> =>
apiFetch(`/api/protocols/${id}`, { method: 'DELETE' }).then(() => undefined),
};

7
lib/auth-client.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createAuthClient } from 'better-auth/react';
export const authClient = createAuthClient({
baseURL: `${window.location.origin}/api/auth`,
});
export const { signIn, signOut, useSession } = authClient;

789
package-lock.json generated
View File

@@ -35,6 +35,7 @@
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/oxide-linux-x64-gnu": "^4.2.2",
"better-auth": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -57,6 +58,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.2.0"
@@ -353,6 +355,122 @@
"node": ">=6.9.0"
}
},
"node_modules/@better-auth/core": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.5.5.tgz",
"integrity": "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"zod": "^4.3.6"
},
"peerDependencies": {
"@better-auth/utils": "0.3.1",
"@better-fetch/fetch": "1.1.21",
"@cloudflare/workers-types": ">=4",
"better-call": "1.3.2",
"jose": "^6.1.0",
"kysely": "^0.28.5",
"nanostores": "^1.0.1"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
"optional": true
}
}
},
"node_modules/@better-auth/drizzle-adapter": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.5.5.tgz",
"integrity": "sha512-HAi9xAP40oDt48QZeYBFTcmg3vt1Jik90GwoRIfangd7VGbxesIIDBJSnvwMbZ52GBIc6+V4FRw9lasNiNrPfw==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.5",
"@better-auth/utils": "^0.3.0",
"drizzle-orm": ">=0.41.0"
},
"peerDependenciesMeta": {
"drizzle-orm": {
"optional": true
}
}
},
"node_modules/@better-auth/kysely-adapter": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.5.5.tgz",
"integrity": "sha512-LmHffIVnqbfsxcxckMOoE8MwibWrbVFch+kwPKJ5OFDFv6lin75ufN7ZZ7twH0IMPLT/FcgzaRjP8jRrXRef9g==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.5",
"@better-auth/utils": "^0.3.0",
"kysely": "^0.27.0 || ^0.28.0"
}
},
"node_modules/@better-auth/memory-adapter": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.5.5.tgz",
"integrity": "sha512-4X0j1/2L+nsgmObjmy9xEGUFWUv38Qjthp558fwS3DAp6ueWWyCaxaD6VJZ7m5qPNMrsBStO5WGP8CmJTEWm7g==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.5",
"@better-auth/utils": "^0.3.0"
}
},
"node_modules/@better-auth/mongo-adapter": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.5.5.tgz",
"integrity": "sha512-P1J9ljL5X5k740I8Rx1esPWNgWYPdJR5hf2CY7BwDSrQFPUHuzeCg0YhtEEP55niNateTXhBqGAcy0fVOeamZg==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.5",
"@better-auth/utils": "^0.3.0",
"mongodb": "^6.0.0 || ^7.0.0"
}
},
"node_modules/@better-auth/prisma-adapter": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.5.5.tgz",
"integrity": "sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg==",
"license": "MIT",
"peerDependencies": {
"@better-auth/core": "1.5.5",
"@better-auth/utils": "^0.3.0",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0"
},
"peerDependenciesMeta": {
"@prisma/client": {
"optional": true
},
"prisma": {
"optional": true
}
}
},
"node_modules/@better-auth/telemetry": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.5.5.tgz",
"integrity": "sha512-1+lklxArn4IMHuU503RcPdXrSG2tlXt4jnGG3omolmspQ7tktg/Y9XO/yAkYDurtvMn1xJ8X1Ov01Ji/r5s9BQ==",
"license": "MIT",
"dependencies": {
"@better-auth/utils": "0.3.1",
"@better-fetch/fetch": "1.1.21"
},
"peerDependencies": {
"@better-auth/core": "1.5.5"
}
},
"node_modules/@better-auth/utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.1.tgz",
"integrity": "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==",
"license": "MIT"
},
"node_modules/@better-fetch/fetch": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz",
"integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@@ -889,6 +1007,40 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
"integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -2855,6 +3007,12 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@tabby_ai/hijri-converter": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz",
@@ -3269,6 +3427,23 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
"license": "MIT",
"peer": true
},
"node_modules/@types/whatwg-url": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/webidl-conversions": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -3290,6 +3465,32 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -3315,6 +3516,131 @@
"node": ">=6.0.0"
}
},
"node_modules/better-auth": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.5.5.tgz",
"integrity": "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg==",
"license": "MIT",
"dependencies": {
"@better-auth/core": "1.5.5",
"@better-auth/drizzle-adapter": "1.5.5",
"@better-auth/kysely-adapter": "1.5.5",
"@better-auth/memory-adapter": "1.5.5",
"@better-auth/mongo-adapter": "1.5.5",
"@better-auth/prisma-adapter": "1.5.5",
"@better-auth/telemetry": "1.5.5",
"@better-auth/utils": "0.3.1",
"@better-fetch/fetch": "1.1.21",
"@noble/ciphers": "^2.1.1",
"@noble/hashes": "^2.0.1",
"better-call": "1.3.2",
"defu": "^6.1.4",
"jose": "^6.1.3",
"kysely": "^0.28.11",
"nanostores": "^1.1.1",
"zod": "^4.3.6"
},
"peerDependencies": {
"@lynx-js/react": "*",
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
"@sveltejs/kit": "^2.0.0",
"@tanstack/react-start": "^1.0.0",
"@tanstack/solid-start": "^1.0.0",
"better-sqlite3": "^12.0.0",
"drizzle-kit": ">=0.31.4",
"drizzle-orm": ">=0.41.0",
"mongodb": "^6.0.0 || ^7.0.0",
"mysql2": "^3.0.0",
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
"pg": "^8.0.0",
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"solid-js": "^1.0.0",
"svelte": "^4.0.0 || ^5.0.0",
"vitest": "^2.0.0 || ^3.0.0 || ^4.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"@lynx-js/react": {
"optional": true
},
"@prisma/client": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"@tanstack/react-start": {
"optional": true
},
"@tanstack/solid-start": {
"optional": true
},
"better-sqlite3": {
"optional": true
},
"drizzle-kit": {
"optional": true
},
"drizzle-orm": {
"optional": true
},
"mongodb": {
"optional": true
},
"mysql2": {
"optional": true
},
"next": {
"optional": true
},
"pg": {
"optional": true
},
"prisma": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vitest": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/better-call": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.2.tgz",
"integrity": "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==",
"license": "MIT",
"dependencies": {
"@better-auth/utils": "^0.3.1",
"@better-fetch/fetch": "^1.1.21",
"rou3": "^0.7.12",
"set-cookie-parser": "^3.0.1"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -3349,6 +3675,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/bson": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=16.20.1"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001780",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
@@ -3370,6 +3706,36 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -3382,6 +3748,21 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3407,6 +3788,51 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -3581,6 +4007,12 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -3642,6 +4074,13 @@
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
@@ -3766,6 +4205,16 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@@ -3782,6 +4231,16 @@
"dev": true,
"license": "ISC"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -3801,6 +4260,16 @@
"node": ">=12"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -3811,6 +4280,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3843,6 +4321,15 @@
"node": ">=6"
}
},
"node_modules/kysely": {
"version": "0.28.13",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.13.tgz",
"integrity": "sha512-jCkYDvlfzOyHaVsrvR4vnNZxG30oNv2jbbFBjTQAUG8n0h07HW0sZJHk4KAQIRyu9ay+Rg+L8qGa3lwt8Gve9w==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -4151,6 +4638,71 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"license": "MIT",
"peer": true
},
"node_modules/mongodb": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz",
"integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@mongodb-js/saslprep": "^1.3.0",
"bson": "^6.10.4",
"mongodb-connection-string-url": "^3.0.2"
},
"engines": {
"node": ">=16.20.1"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.188.0",
"@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
"gcp-metadata": "^5.2.0",
"kerberos": "^2.0.1",
"mongodb-client-encryption": ">=6.0.0 <7",
"snappy": "^7.3.2",
"socks": "^2.7.1"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
},
"@mongodb-js/zstd": {
"optional": true
},
"gcp-metadata": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"snappy": {
"optional": true
},
"socks": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4177,6 +4729,21 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanostores": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz",
"integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"engines": {
"node": "^20.0.0 || >=22.0.0"
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
@@ -4269,6 +4836,16 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -4486,6 +5063,16 @@
"decimal.js-light": "^2.4.1"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -4531,6 +5118,22 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rou3": {
"version": "0.7.12",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
"integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==",
"license": "MIT"
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -4547,6 +5150,25 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
"integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==",
"license": "MIT"
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@@ -4567,6 +5189,60 @@
"node": ">=0.10.0"
}
},
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
@@ -4621,6 +5297,29 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"peer": true,
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -4834,12 +5533,102 @@
}
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"peer": true,
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -3,7 +3,9 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "concurrently -n \"client,server\" -c \"blue,green\" \"vite\" \"npm run dev --prefix server\"",
"dev:client": "vite",
"dev:server": "npm run dev --prefix server",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
@@ -50,7 +52,8 @@
"recharts": "^2.15.2",
"sonner": "^2.0.3",
"tailwind-merge": "^2.6.0",
"vaul": "^1.1.2"
"vaul": "^1.1.2",
"better-auth": "^1.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
@@ -59,6 +62,7 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.2.0"
"vite": "^6.2.0",
"concurrently": "^9.1.2"
}
}

2363
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
server/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "labwise-server",
"version": "1.0.0",
"type": "commonjs",
"scripts": {
"dev": "tsx watch --env-file=.env src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "tsx src/db/migrate.ts"
},
"dependencies": {
"better-auth": "^1.5.5",
"cors": "^2.8.5",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.13.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.1",
"@types/multer": "^1.4.12",
"@types/node": "^22.13.10",
"@types/pg": "^8.11.11",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
}
}

37
server/src/auth/auth.ts Normal file
View File

@@ -0,0 +1,37 @@
import { betterAuth } from 'better-auth';
import { kyselyAdapter } from '@better-auth/kysely-adapter';
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
const db = new Kysely({
dialect: new PostgresDialect({
pool: new Pool({
connectionString: process.env.DATABASE_URL ||
'postgresql://labwise:labwise_dev_pw@localhost:5432/labwise_db',
}),
}),
});
export const auth = betterAuth({
database: kyselyAdapter(db, { type: 'postgres' }),
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3001',
secret: process.env.BETTER_AUTH_SECRET || 'dev-secret-change-in-production-min32chars!!',
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
},
microsoft: {
clientId: process.env.MICROSOFT_CLIENT_ID || '',
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || '',
tenantId: process.env.MICROSOFT_TENANT_ID || 'common',
},
},
trustedOrigins: [
'http://localhost:5173',
'https://labwise.wahwa.com',
],
});

View File

@@ -0,0 +1,30 @@
import { Request, Response, NextFunction } from 'express';
import { auth } from './auth';
import { fromNodeHeaders } from 'better-auth/node';
declare global {
namespace Express {
interface Request {
user?: { id: string; email: string; name: string };
sessionId?: string;
}
}
}
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
try {
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});
if (!session) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.user = session.user;
req.sessionId = session.session.id;
next();
} catch {
return res.status(401).json({ error: 'Unauthorized' });
}
}

View File

@@ -0,0 +1,16 @@
import rateLimit from 'express-rate-limit';
export const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many auth attempts, please try again later' },
});
export const apiRateLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 200,
standardHeaders: true,
legacyHeaders: false,
});

15
server/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,15 @@
import { pool } from './pool';
import fs from 'fs';
import path from 'path';
async function migrate() {
const sql = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf-8');
await pool.query(sql);
console.log('Migration complete');
await pool.end();
}
migrate().catch(err => {
console.error('Migration failed:', err);
process.exit(1);
});

9
server/src/db/pool.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Pool } from 'pg';
export const pool = new Pool({
connectionString: process.env.DATABASE_URL ||
'postgresql://labwise:labwise_dev_pw@localhost:5432/labwise_db',
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});

63
server/src/db/schema.sql Normal file
View File

@@ -0,0 +1,63 @@
-- Chemical inventory, scoped per user
CREATE TABLE IF NOT EXISTS chemicals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
-- Required fields
pi_first_name TEXT NOT NULL,
physical_state TEXT NOT NULL,
chemical_name TEXT NOT NULL,
bldg_code TEXT NOT NULL,
lab TEXT NOT NULL,
storage_location TEXT NOT NULL,
storage_device TEXT NOT NULL,
number_of_containers TEXT NOT NULL,
amount_per_container TEXT NOT NULL,
unit_of_measure TEXT NOT NULL,
cas_number TEXT NOT NULL,
-- Optional fields
chemical_formula TEXT,
molecular_weight TEXT,
vendor TEXT,
catalog_number TEXT,
found_in_catalog TEXT,
po_number TEXT,
receipt_date TEXT,
open_date TEXT,
max_on_hand TEXT,
expiration_date DATE,
contact TEXT,
comments TEXT,
permit_number TEXT,
barcode TEXT,
concentration TEXT,
chemical_number TEXT,
lot_number TEXT,
multiple_cas TEXT,
msds TEXT,
percentage_full NUMERIC(5,2),
needs_manual_entry TEXT[],
scanned_image TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS chemicals_user_id_idx ON chemicals(user_id);
CREATE INDEX IF NOT EXISTS chemicals_cas_number_idx ON chemicals(cas_number);
-- Protocols with JSONB analysis results
CREATE TABLE IF NOT EXISTS protocols (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
file_url TEXT,
analysis_results JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS protocols_user_id_idx ON protocols(user_id);

44
server/src/index.ts Normal file
View File

@@ -0,0 +1,44 @@
import express from 'express';
import cors from 'cors';
import { toNodeHandler } from 'better-auth/node';
import { auth } from './auth/auth';
import { authRateLimiter, apiRateLimiter } from './auth/rateLimiter';
import chemicalsRouter from './routes/chemicals';
import protocolsRouter from './routes/protocols';
import path from 'path';
const app = express();
const PORT = process.env.PORT || 3001;
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, '../uploads');
// Trust Cloudflare/proxy X-Forwarded-For headers
app.set('trust proxy', 1);
app.use(cors({
origin: [
'http://localhost:5173',
'https://labwise.wahwa.com',
],
credentials: true,
}));
// Serve uploaded files
app.use('/uploads', express.static(UPLOADS_DIR));
// Better Auth — must come before express.json() so it can read its own body
app.use('/api/auth/*', authRateLimiter);
app.all('/api/auth/*', toNodeHandler(auth));
// Body parsing for all other routes
app.use(express.json({ limit: '1mb' }));
// Application routes
app.use('/api', apiRateLimiter);
app.use('/api/chemicals', chemicalsRouter);
app.use('/api/protocols', protocolsRouter);
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.listen(PORT, () => {
console.log(`LabWise API running on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,106 @@
import { Router } from 'express';
import { pool } from '../db/pool';
import { requireAuth } from '../auth/middleware';
const router = Router();
router.use(requireAuth);
function camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`);
}
// GET /api/chemicals
router.get('/', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM chemicals WHERE user_id = $1 ORDER BY created_at DESC',
[req.user!.id]
);
// Map snake_case columns back to camelCase for the frontend
const rows = result.rows.map(snakeToCamel);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/chemicals
router.post('/', async (req, res) => {
try {
const b = req.body;
const result = await pool.query(`
INSERT INTO chemicals (
user_id, pi_first_name, physical_state, chemical_name, bldg_code, lab,
storage_location, storage_device, number_of_containers, amount_per_container,
unit_of_measure, cas_number,
chemical_formula, molecular_weight, vendor, catalog_number, lot_number,
expiration_date, concentration, percentage_full, needs_manual_entry,
scanned_image, comments, barcode, contact
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,
$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25
) RETURNING *`,
[
req.user!.id, b.piFirstName, b.physicalState, b.chemicalName, b.bldgCode, b.lab,
b.storageLocation, b.storageDevice, b.numberOfContainers, b.amountPerContainer,
b.unitOfMeasure, b.casNumber,
b.chemicalFormula ?? null, b.molecularWeight ?? null, b.vendor ?? null,
b.catalogNumber ?? null, b.lotNumber ?? null,
b.expirationDate ?? null, b.concentration ?? null,
b.percentageFull ?? null, b.needsManualEntry ?? null,
b.scannedImage ?? null, b.comments ?? null, b.barcode ?? null, b.contact ?? null,
]
);
res.status(201).json(snakeToCamel(result.rows[0]));
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// PATCH /api/chemicals/:id
router.patch('/:id', async (req, res) => {
try {
const fields = Object.keys(req.body);
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
const snakeFields = fields.map(camelToSnake);
const setClauses = snakeFields.map((f, i) => `${f} = $${i + 3}`).join(', ');
const values = fields.map(f => req.body[f]);
const result = await pool.query(
`UPDATE chemicals SET ${setClauses}, updated_at = NOW()
WHERE id = $1 AND user_id = $2 RETURNING *`,
[req.params.id, req.user!.id, ...values]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(snakeToCamel(result.rows[0]));
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// DELETE /api/chemicals/:id
router.delete('/:id', async (req, res) => {
try {
await pool.query('DELETE FROM chemicals WHERE id = $1 AND user_id = $2',
[req.params.id, req.user!.id]);
res.status(204).end();
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
function snakeToCamel(row: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(row).map(([k, v]) => [
k.replace(/_([a-z])/g, (_, l) => l.toUpperCase()),
v,
])
);
}
export default router;

View File

@@ -0,0 +1,110 @@
import { Router } from 'express';
import { pool } from '../db/pool';
import { requireAuth } from '../auth/middleware';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
const router = Router();
router.use(requireAuth);
const uploadsDir = process.env.UPLOADS_DIR || path.join(__dirname, '../../uploads');
fs.mkdirSync(uploadsDir, { recursive: true });
const storage = multer.diskStorage({
destination: (req, _file, cb) => {
const userDir = path.join(uploadsDir, (req as any).user.id);
fs.mkdirSync(userDir, { recursive: true });
cb(null, userDir);
},
filename: (_req, file, cb) => {
const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, `${unique}${path.extname(file.originalname)}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.pdf', '.doc', '.docx', '.txt'];
if (allowed.includes(path.extname(file.originalname).toLowerCase())) {
cb(null, true);
} else {
cb(new Error('Only PDF, DOC, DOCX, TXT files allowed'));
}
},
});
// GET /api/protocols
router.get('/', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM protocols WHERE user_id = $1 ORDER BY created_at DESC',
[req.user!.id]
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/protocols
router.post('/', upload.single('file'), async (req, res) => {
try {
const { title, content } = req.body;
const userId = (req as any).user.id;
const fileUrl = req.file ? `/uploads/${userId}/${req.file.filename}` : null;
const result = await pool.query(
`INSERT INTO protocols (user_id, title, content, file_url)
VALUES ($1, $2, $3, $4) RETURNING *`,
[req.user!.id, title || 'Untitled Protocol', content || '', fileUrl]
);
res.status(201).json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// PATCH /api/protocols/:id/analysis
router.patch('/:id/analysis', async (req, res) => {
try {
const { analysis_results } = req.body;
const result = await pool.query(
`UPDATE protocols SET analysis_results = $1::jsonb, updated_at = NOW()
WHERE id = $2 AND user_id = $3 RETURNING *`,
[JSON.stringify(analysis_results), req.params.id, req.user!.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
// DELETE /api/protocols/:id
router.delete('/:id', async (req, res) => {
try {
const result = await pool.query(
'DELETE FROM protocols WHERE id = $1 AND user_id = $2 RETURNING file_url',
[req.params.id, req.user!.id]
);
const fileUrl = result.rows[0]?.file_url;
if (fileUrl) {
// fileUrl is like /uploads/<userId>/<filename>
const relative = fileUrl.replace(/^\/uploads\//, '');
const filePath = path.join(uploadsDir, relative);
fs.rmSync(filePath, { force: true });
}
res.status(204).end();
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

15
server/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

60
shared/types.ts Normal file
View File

@@ -0,0 +1,60 @@
export interface ChemicalInventory {
id: string;
user_id: string;
piFirstName: string;
physicalState: string;
chemicalName: string;
bldgCode: string;
lab: string;
storageLocation: string;
storageDevice: string;
numberOfContainers: string;
amountPerContainer: string;
unitOfMeasure: string;
casNumber: string;
chemicalFormula?: string;
molecularWeight?: string;
vendor?: string;
catalogNumber?: string;
foundInCatalog?: string;
poNumber?: string;
receiptDate?: string;
openDate?: string;
maxOnHand?: string;
expirationDate?: string;
contact?: string;
comments?: string;
dateEntered?: string;
permitNumber?: string;
barcode?: string;
lastChanged?: string;
concentration?: string;
chemicalNumber?: string;
lotNumber?: string;
multipleCAS?: string;
msds?: string;
percentageFull?: number;
needsManualEntry?: string[];
scannedImage?: string;
created_at?: string;
updated_at?: string;
}
export interface SafetyIssue {
type: "critical" | "warning" | "suggestion";
category: string;
message: string;
source?: string;
sourceUrl?: string;
}
export interface Protocol {
id: string;
user_id: string;
title: string;
content: string;
file_url?: string;
analysis_results?: SafetyIssue[];
created_at: string;
updated_at: string;
}

View File

@@ -7,5 +7,9 @@ export default defineConfig({
server: {
host: true,
allowedHosts: ["labwise.wahwa.com"],
proxy: {
'/api': { target: 'http://localhost:3001', changeOrigin: true },
'/uploads': { target: 'http://localhost:3001', changeOrigin: true },
},
},
});