From 163ce564e5b98e584ac075af78c4fe255c58f770 Mon Sep 17 00:00:00 2001 From: pulipakaa24 Date: Sat, 4 Apr 2026 23:11:51 -0500 Subject: [PATCH] Better form control and profile page --- App.tsx | 24 +++-- components/Inventory.tsx | 58 ++++++++++- components/Onboarding.tsx | 16 ++- components/ProfileSettings.tsx | 176 +++++++++++++++++++++++++++++++++ components/auth/SignUpForm.tsx | 15 ++- lib/validators.ts | 47 +++++++++ 6 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 components/ProfileSettings.tsx create mode 100644 lib/validators.ts diff --git a/App.tsx b/App.tsx index 1cad9c3..5e5ce9e 100644 --- a/App.tsx +++ b/App.tsx @@ -3,13 +3,14 @@ import { Dashboard } from './components/Dashboard'; import { Inventory } from './components/Inventory'; import { ProtocolChecker } from './components/ProtocolChecker'; import { Onboarding } from './components/Onboarding'; +import { ProfileSettings } from './components/ProfileSettings'; import { LoginForm } from './components/auth/LoginForm'; import { SignUpForm } from './components/auth/SignUpForm'; import { EmailVerification } from './components/auth/EmailVerification'; import { ForgotPassword } from './components/auth/ForgotPassword'; import { ResetPassword } from './components/auth/ResetPassword'; import { useSession, signOut } from './lib/auth-client'; -import { LayoutDashboard, Package, FileCheck, LogOut } from 'lucide-react'; +import { LayoutDashboard, Package, FileCheck, LogOut, UserCircle } from 'lucide-react'; const logo = '/logo.png'; @@ -23,7 +24,7 @@ type AppView = | 'onboarding' | 'app'; -type Tab = 'dashboard' | 'inventory' | 'protocol'; +type Tab = 'dashboard' | 'inventory' | 'protocol' | 'profile'; function isResetPasswordRoute() { return ( @@ -135,10 +136,20 @@ export default function App() { ); })} -
-
- {session.user.email} -
+
+
); diff --git a/components/Inventory.tsx b/components/Inventory.tsx index 85fe4d5..9d76fe8 100644 --- a/components/Inventory.tsx +++ b/components/Inventory.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; import ExcelJS from "exceljs"; import { chemicalsApi } from "../lib/api"; +import { validateCAS, validateNumber, validatePhoneOrEmail } from "../lib/validators"; import type { ChemicalInventory } from "../shared/types"; import { Card } from "./ui/card"; import { Button } from "./ui/button"; @@ -199,6 +200,30 @@ export function Inventory() { setFormError("Please fill in all required fields."); return; } + if (!validateCAS(String(form.casNumber || ""))) { + setFormError("CAS # must be in the format ##-##-# (e.g. 67-56-1)."); + return; + } + if (!validateNumber(form.numberOfContainers, { min: 1, integer: true })) { + setFormError("# of containers must be a whole number of 1 or more."); + return; + } + if (!validateNumber(form.amountPerContainer, { min: 0 })) { + setFormError("Amount per container must be a number."); + return; + } + if (form.molecularWeight && !validateNumber(form.molecularWeight, { min: 0 })) { + setFormError("Molecular weight must be a number."); + return; + } + if (form.percentageFull != null && !validateNumber(form.percentageFull, { min: 0, max: 100 })) { + setFormError("% full must be between 0 and 100."); + return; + } + if (form.contact && !validatePhoneOrEmail(String(form.contact))) { + setFormError("Contact must be a valid phone number or email address."); + return; + } setFormError(""); setIsSaving(true); try { @@ -963,7 +988,14 @@ export function Inventory() {
- setField("casNumber", e.target.value)} placeholder="e.g. 67-56-1" /> + setField("casNumber", e.target.value)} + placeholder="e.g. 67-56-1" + inputMode="numeric" + pattern="\d{2,7}-\d{2}-\d" + title="CAS format: digits-digits-digit (e.g. 67-56-1)" + />
@@ -1009,7 +1041,14 @@ export function Inventory() {
- setField("amountPerContainer", e.target.value)} placeholder="e.g. 500" /> + setField("amountPerContainer", e.target.value)} + placeholder="e.g. 500" + />
@@ -1045,7 +1084,14 @@ export function Inventory() {
- setField("molecularWeight", e.target.value)} placeholder="g/mol" /> + setField("molecularWeight", e.target.value)} + placeholder="g/mol" + />
@@ -1077,7 +1123,11 @@ export function Inventory() {
- setField("contact", e.target.value)} /> + setField("contact", e.target.value)} + placeholder="Phone (e.g. 555-123-4567) or email" + />
diff --git a/components/Onboarding.tsx b/components/Onboarding.tsx index 26010ac..630608a 100644 --- a/components/Onboarding.tsx +++ b/components/Onboarding.tsx @@ -4,6 +4,7 @@ import { Card, CardContent } from './ui/card'; import { Input } from './ui/input'; import { Label } from './ui/label'; import { useSession } from '../lib/auth-client'; +import { validatePhoneOrEmail } from '../lib/validators'; const logo = '/logo.png'; @@ -23,16 +24,21 @@ export function Onboarding({ onComplete }: Props) { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(''); + const trimmedContact = contact.trim(); + if (trimmedContact && !validatePhoneOrEmail(trimmedContact)) { + setError('Contact must be a valid phone number or email address.'); + return; + } setLoading(true); const res = await fetch('/api/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ - pi_first_name: piFirstName, - bldg_code: bldgCode, - lab, - contact: contact || undefined, + pi_first_name: piFirstName.trim(), + bldg_code: bldgCode.trim(), + lab: lab.trim(), + contact: trimmedContact || undefined, }), }); setLoading(false); @@ -103,7 +109,7 @@ export function Onboarding({ onComplete }: Props) { type="text" value={contact} onChange={e => setContact(e.target.value)} - placeholder="Phone or email" + placeholder="Phone (e.g. 555-123-4567) or email" />
{error &&

{error}

} diff --git a/components/ProfileSettings.tsx b/components/ProfileSettings.tsx new file mode 100644 index 0000000..0547be1 --- /dev/null +++ b/components/ProfileSettings.tsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from 'react'; +import { Loader2, Check } from 'lucide-react'; +import { Button } from './ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { useSession } from '../lib/auth-client'; +import { validatePhoneOrEmail } from '../lib/validators'; + +export function ProfileSettings() { + const { data: session } = useSession(); + const [piFirstName, setPiFirstName] = useState(''); + const [bldgCode, setBldgCode] = useState(''); + const [lab, setLab] = useState(''); + const [contact, setContact] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [saved, setSaved] = useState(false); + + useEffect(() => { + fetch('/api/profile', { credentials: 'include' }) + .then(r => (r.ok ? r.json() : null)) + .then(data => { + if (data) { + setPiFirstName(data.pi_first_name || ''); + setBldgCode(data.bldg_code || ''); + setLab(data.lab || ''); + setContact(data.contact || ''); + } + }) + .finally(() => setLoading(false)); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSaved(false); + + if (!piFirstName.trim() || !bldgCode.trim() || !lab.trim()) { + setError('PI first name, building code, and lab are required.'); + return; + } + if (contact.trim() && !validatePhoneOrEmail(contact.trim())) { + setError('Contact must be a valid phone number or email address.'); + return; + } + + setSaving(true); + const res = await fetch('/api/profile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + pi_first_name: piFirstName.trim(), + bldg_code: bldgCode.trim(), + lab: lab.trim(), + contact: contact.trim() || undefined, + }), + }); + setSaving(false); + + if (res.ok) { + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } else { + const data = await res.json().catch(() => ({})); + setError(data.error || 'Failed to save profile.'); + } + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Profile settings

+

+ Update your account details and lab defaults. +

+
+ + + + Account + + +
+ +

{session?.user.name || '—'}

+
+
+ +

{session?.user.email || '—'}

+
+
+
+ +
+ + + Lab defaults + + +
+ + setPiFirstName(e.target.value)} + required + placeholder="e.g. Smith" + /> +
+
+ + setBldgCode(e.target.value)} + required + placeholder="e.g. EER" + /> +
+
+ + setLab(e.target.value)} + required + placeholder="e.g. 3.822" + /> +
+
+ + setContact(e.target.value)} + placeholder="Phone (e.g. 555-123-4567) or email" + /> +
+ {error &&

{error}

} + {saved && ( +

+ Saved +

+ )} +
+ +
+
+
+
+
+ ); +} diff --git a/components/auth/SignUpForm.tsx b/components/auth/SignUpForm.tsx index 8212d01..b53256e 100644 --- a/components/auth/SignUpForm.tsx +++ b/components/auth/SignUpForm.tsx @@ -12,6 +12,7 @@ import { DialogFooter, } from '../ui/dialog'; import { signUp, sendVerificationEmail } from '../../lib/auth-client'; +import { validateEmail } from '../../lib/validators'; import { useEffect } from 'react'; const logo = '/logo.png'; @@ -48,6 +49,16 @@ export function SignUpForm({ onLogin }: Props) { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(''); + const trimmedName = name.trim(); + const trimmedEmail = email.trim(); + if (trimmedName.length < 2) { + setError('Please enter your full name.'); + return; + } + if (!validateEmail(trimmedEmail)) { + setError('Please enter a valid email address.'); + return; + } if (password.length < 8) { setError('Password must be at least 8 characters'); return; @@ -58,8 +69,8 @@ export function SignUpForm({ onLogin }: Props) { } setLoading(true); const res = await signUp.email({ - name, - email, + name: trimmedName, + email: trimmedEmail, password, callbackURL: window.location.origin, }); diff --git a/lib/validators.ts b/lib/validators.ts new file mode 100644 index 0000000..8492fd2 --- /dev/null +++ b/lib/validators.ts @@ -0,0 +1,47 @@ +// Shared input validators for sign-up, profile, and inventory forms. + +export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// Phone: allows digits, spaces, parens, +, -, . — must contain at least 7 digits. +export const PHONE_REGEX = /^\+?[\d\s().\-]{7,20}$/; + +// CAS Registry Number: 2–7 digits, 2 digits, 1 check digit (e.g. 67-56-1). +export const CAS_REGEX = /^\d{2,7}-\d{2}-\d$/; + +export function validateEmail(value: string): boolean { + return EMAIL_REGEX.test(value.trim()); +} + +export function validatePhone(value: string): boolean { + const trimmed = value.trim(); + if (!PHONE_REGEX.test(trimmed)) return false; + // Require at least 7 actual digits to avoid strings of only punctuation. + return trimmed.replace(/\D/g, '').length >= 7; +} + +export function validatePhoneOrEmail(value: string): boolean { + return validateEmail(value) || validatePhone(value); +} + +export function validateCAS(value: string): boolean { + return CAS_REGEX.test(value.trim()); +} + +export interface NumberOpts { + min?: number; + max?: number; + integer?: boolean; +} + +export function validateNumber( + value: string | number | undefined | null, + opts: NumberOpts = {}, +): boolean { + if (value == null || value === '') return false; + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n)) return false; + if (opts.integer && !Number.isInteger(n)) return false; + if (opts.min != null && n < opts.min) return false; + if (opts.max != null && n > opts.max) return false; + return true; +}