From edc9f3da740ecf121a8c0d9481c1552661aa012d Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 21 Oct 2026 03:13:01 +0200 Subject: [PATCH] feat(settings): add user management, fix totals and mobile layout Adds a Settings > Users screen where members can be added, turned off and removed, and fixes three defects found while auditing the app on a phone. User management - Settings is a real page with a Users section: add a member (name, email, password), flip "Can sign in" to block or restore access without deleting their history, or remove them outright. - The app has no roles - membership is just the active flag - so the Settings route no longer requires the "admin" role, which left it unreachable for every real member. Settings is now in the sidebar. - New members are created with active: true. AuthContext refuses to sign in a user whose active flag is false, and the field had no default, so previously created users were locked out of the app. Live PocketBase rules for the `users` collection (see scripts/fix-users-collection.mjs): - createRule was "" which means PUBLIC - any anonymous visitor could create an account on the instance. It now requires an authenticated member. - listRule was "id = @request.auth.id", so a member could only ever see their own record and no member list could be rendered. It now matches every other collection in the app: @request.auth.id != "". Totals always showed R0.00 - form.watch("items") returns the same array instance that react-hook-form mutates in place, so the useMemo keyed on it never recomputed. Both the quote and purchase order totals stayed at zero however many items were added. Switched to useWatch with compute, which re-runs whenever the watched field really changes. Mobile layout - The page was zoomed out on phones: flex items default to min-width:auto, so a wide table stretched the layout past the viewport. Added min-w-0 to the layout chain. - The sr-only labels inside the tables are position:absolute with no positioned ancestor, so they escaped the table's overflow-x-auto clip and stretched the document. Made the scroll containers relative. - DialogContent was w-full plus an mx-4 gutter, which overflowed the viewport by 16px and clipped the right edge of every form. The component now owns the gutter itself. Verified at 390x844: all pages and the quote, purchase order and add user dialogs fit the viewport with no horizontal overflow. --- scripts/fix-users-collection.mjs | 82 ++++ src/App.tsx | 2 +- src/components/Layout.tsx | 7 +- src/components/SidebarNav.tsx | 7 +- .../purchase-orders/AttachmentsDialog.tsx | 2 +- .../EditPurchaseOrderDialog.tsx | 2 +- .../PurchaseOrderDataTable.tsx | 5 +- .../purchase-orders/PurchaseOrderForm.tsx | 22 +- .../purchase-orders/ViewOrderDialog.tsx | 2 +- src/components/quotes/QuoteDataTable.tsx | 9 +- src/components/quotes/QuoteForm.tsx | 28 +- src/components/settings/UserManagement.tsx | 441 ++++++++++++++++++ src/components/ui/dialog.tsx | 2 +- src/pages/BookOut.tsx | 2 +- src/pages/Customers.tsx | 2 +- src/pages/PurchaseOrders.tsx | 2 +- src/pages/Quotes.tsx | 2 +- src/pages/Settings.tsx | 23 +- src/pages/Stock.tsx | 7 +- src/pages/Suppliers.tsx | 2 +- 20 files changed, 605 insertions(+), 46 deletions(-) create mode 100644 scripts/fix-users-collection.mjs create mode 100644 src/components/settings/UserManagement.tsx diff --git a/scripts/fix-users-collection.mjs b/scripts/fix-users-collection.mjs new file mode 100644 index 0000000..f4f9c94 --- /dev/null +++ b/scripts/fix-users-collection.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Harden the `users` collection and make it usable by the app's + * Settings > Users screen. + * + * Problems found on the live instance: + * + * 1. createRule was "" (empty string) => PUBLIC. Any anonymous visitor + * on the internet could create an account on this PocketBase instance. + * 2. listRule was "id = @request.auth.id" => a signed-in member could + * only ever see their own record, so there was no way to render a + * member list. + * 3. The `active` bool field had no default, so every record was created + * with active = false. AuthContext refuses to sign in a user whose + * `active` is false, so newly created users could not log in at all. + * NOTE: PocketBase's REST API strips `defaultValue` from field + * definitions (verified on this instance - a scratch collection with + * `defaultValue: true` on a bool field still stored false), so the + * default cannot be set from here. The app sets `active: true` + * explicitly when it creates a member instead. + * + * New rules (matching the posture already used by every other collection + * in this app - stock_items, customers, purchase_orders, quotes, ...): + * + * listRule @request.auth.id != "" members can list the team + * viewRule @request.auth.id != "" members can read a member + * createRule @request.auth.id != "" members can add members + * updateRule @request.auth.id != "" members can activate/deactivate + * deleteRule @request.auth.id != "" members can remove members + * + * The `role` field is intentionally left untouched: the app does not use + * roles, membership is just the `active` flag. + * + * Usage: + * node scripts/fix-users-collection.mjs # dry run (default) + * node scripts/fix-users-collection.mjs --apply # apply to the instance + */ + +import PocketBase from "pocketbase"; + +const PB_URL = process.env.POCKETBASE_URL || "https://pb.jeistk.apphub.co.za"; +const PB_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || "david@wfsales.co.za"; +const PB_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD || "1234567890"; + +const APPLY = process.argv.includes("--apply"); + +const AUTHENTICATED = '@request.auth.id != ""'; + +const pb = new PocketBase(PB_URL); +pb.autoCancellation(false); + +await pb.admins.authWithPassword(PB_EMAIL, PB_PASSWORD); + +const collection = await pb.collections.getOne("users"); + +console.log("=== BEFORE ==="); +for (const rule of ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"]) { + console.log(` ${rule.padEnd(11)}: ${JSON.stringify(collection[rule])}`); +} +const activeField = collection.fields.find((f) => f.name === "active"); +console.log(` active field: ${activeField ? "present" : "MISSING"}`); + +if (!APPLY) { + console.log("\nDry run. Re-run with --apply to write these rules to the instance."); + process.exit(0); +} + +collection.listRule = AUTHENTICATED; +collection.viewRule = AUTHENTICATED; +collection.createRule = AUTHENTICATED; +collection.updateRule = AUTHENTICATED; +collection.deleteRule = AUTHENTICATED; + +const updated = await pb.collections.update(collection.id, collection); + +console.log("\n=== AFTER ==="); +for (const rule of ["listRule", "viewRule", "createRule", "updateRule", "deleteRule"]) { + console.log(` ${rule.padEnd(11)}: ${JSON.stringify(updated[rule])}`); +} + +console.log("\nDone. Existing records keep their current `active` value - flip them"); +console.log("from Settings > Users if they should be able to sign in."); diff --git a/src/App.tsx b/src/App.tsx index 7fe075c..0d4d186 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -91,7 +91,7 @@ const App = () => ( } /> + } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index c5b3365..9b961d7 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -9,9 +9,12 @@ const Layout: React.FC = ({ children }) => { return (
-
+ {/* min-w-0: flex items default to min-width:auto, so a wide table + inside main stretches this column past the viewport. Chrome then + zooms the whole page out on phones. */} +
-
+
{children}
diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 2f951c2..c68e32a 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -1,7 +1,7 @@ import { Link, useLocation } from "react-router-dom"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; -import { Home, Book, Users, ArrowLeftRight, Package, ShoppingCart, Truck, ScrollText, FileText } from "lucide-react"; +import { Home, Book, Users, ArrowLeftRight, Package, ShoppingCart, Truck, ScrollText, FileText, Settings } from "lucide-react"; const sidebarNavItems = [ { @@ -49,6 +49,11 @@ const sidebarNavItems = [ href: "/logs", icon: ScrollText, }, + { + title: "Settings", + href: "/settings", + icon: Settings, + }, ]; interface SidebarNavProps { diff --git a/src/components/purchase-orders/AttachmentsDialog.tsx b/src/components/purchase-orders/AttachmentsDialog.tsx index 41f0f90..8b87858 100644 --- a/src/components/purchase-orders/AttachmentsDialog.tsx +++ b/src/components/purchase-orders/AttachmentsDialog.tsx @@ -144,7 +144,7 @@ export function AttachmentsDialog({ return ( { if (!next) handleCancel() }}> - + {title} {subtitle ? ( diff --git a/src/components/purchase-orders/EditPurchaseOrderDialog.tsx b/src/components/purchase-orders/EditPurchaseOrderDialog.tsx index e1c6a23..2eb8276 100644 --- a/src/components/purchase-orders/EditPurchaseOrderDialog.tsx +++ b/src/components/purchase-orders/EditPurchaseOrderDialog.tsx @@ -181,7 +181,7 @@ export function EditPurchaseOrderDialog({ open, onOpenChange, order, refreshData return ( - + Edit Purchase Order diff --git a/src/components/purchase-orders/PurchaseOrderDataTable.tsx b/src/components/purchase-orders/PurchaseOrderDataTable.tsx index 67a1f6c..55085ef 100644 --- a/src/components/purchase-orders/PurchaseOrderDataTable.tsx +++ b/src/components/purchase-orders/PurchaseOrderDataTable.tsx @@ -286,7 +286,10 @@ export function PurchaseOrderDataTable({
-
+ {/* relative: the sr-only labels inside the table are position:absolute. + Without a positioned ancestor they escape this scroll container and + stretch the whole page, which makes Chrome zoom out on phones. */} +
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/src/components/purchase-orders/PurchaseOrderForm.tsx b/src/components/purchase-orders/PurchaseOrderForm.tsx index 9e3bfcd..e9a5b16 100644 --- a/src/components/purchase-orders/PurchaseOrderForm.tsx +++ b/src/components/purchase-orders/PurchaseOrderForm.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" -import { useForm, useFieldArray } from "react-hook-form" +import { useForm, useFieldArray, useWatch } from "react-hook-form" import * as z from "zod" import { useState, useEffect, useCallback } from "react" // Import useState and useEffect @@ -194,20 +194,24 @@ export function PurchaseOrderForm({ setOpen, refreshData }: PurchaseOrderFormPro name: "items", }); - const itemsValues = form.watch("items") as ItemSchemaType[]; - const subtotalGBP = React.useMemo( - () => - (itemsValues || []).reduce( + // Live total. `form.watch("items")` returns the SAME array instance that + // react-hook-form mutates in place, so a useMemo keyed on it never + // recomputes and the total stays at £0.00. `useWatch` + `compute` re-runs + // the calculation whenever the watched field actually changes. + const subtotalGBP = useWatch({ + control: form.control, + name: "items", + compute: (items: ItemSchemaType[]) => + (items || []).reduce( (sum, item) => { - const price = item.entryMode === 'manual' - ? (Number(item.manualUnitPrice) || 0) + const price = item?.entryMode === 'manual' + ? (Number(item?.manualUnitPrice) || 0) : (Number(item?.costPrice) || 0) return sum + (Number(item?.quantity || 0) * price) }, 0 ), - [itemsValues] - ); + }); // Function to add a new item row diff --git a/src/components/purchase-orders/ViewOrderDialog.tsx b/src/components/purchase-orders/ViewOrderDialog.tsx index 2ade32e..29aedcb 100644 --- a/src/components/purchase-orders/ViewOrderDialog.tsx +++ b/src/components/purchase-orders/ViewOrderDialog.tsx @@ -24,7 +24,7 @@ interface ViewOrderDialogProps { export function ViewOrderDialog({ open, onOpenChange, order }: ViewOrderDialogProps) { return ( - + Purchase Order Details diff --git a/src/components/quotes/QuoteDataTable.tsx b/src/components/quotes/QuoteDataTable.tsx index 79e34e7..76085c1 100644 --- a/src/components/quotes/QuoteDataTable.tsx +++ b/src/components/quotes/QuoteDataTable.tsx @@ -82,7 +82,10 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt -
+ {/* relative: the sr-only labels inside the table are position:absolute. + Without a positioned ancestor they escape this scroll container and + stretch the whole page, which makes Chrome zoom out on phones. */} +
{table.getHeaderGroups().map(hg => ( @@ -111,7 +114,7 @@ export function QuoteDataTable({ columns: columnsProp, data, filterValue, onFilt {/* View Dialog */} - + Quote Details {selectedQuote?.quote_number} @@ -176,7 +179,7 @@ function QuoteAttachmentsDialog({ open, onOpenChange, quote, refreshData }: { return ( - + Quote Attachments {quote.quote_number} diff --git a/src/components/quotes/QuoteForm.tsx b/src/components/quotes/QuoteForm.tsx index c553e08..f8cd9d8 100644 --- a/src/components/quotes/QuoteForm.tsx +++ b/src/components/quotes/QuoteForm.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" -import { useForm, useFieldArray } from "react-hook-form" +import { useForm, useFieldArray, useWatch } from "react-hook-form" import * as z from "zod" import { useState, useEffect, useCallback } from "react" import { Button } from "@/components/ui/button" @@ -112,16 +112,22 @@ export function QuoteForm({ setOpen, refreshData }: QuoteFormProps) { }, [quoteNumber, form]) const { fields, append, remove } = useFieldArray({ control: form.control, name: "items" }) - const itemsValues = form.watch("items") as QuoteItemSchemaType[] - const totalZAR = React.useMemo( - () => (itemsValues || []).reduce((sum, item) => { - const price = item.entryMode === 'manual' - ? (Number(item.manualUnitPrice) || 0) - : (Number(item.unitPrice) || 0) - return sum + (Number(item.quantity || 0) * price) - }, 0), - [itemsValues] - ) + + // Live total. `form.watch("items")` returns the SAME array instance that + // react-hook-form mutates in place, so a useMemo keyed on it never + // recomputes and the total stays at R0.00. `useWatch` + `compute` re-runs + // the calculation whenever the watched field actually changes. + const totalZAR = useWatch({ + control: form.control, + name: "items", + compute: (items: QuoteItemSchemaType[]) => + (items || []).reduce((sum, item) => { + const price = item?.entryMode === 'manual' + ? (Number(item?.manualUnitPrice) || 0) + : (Number(item?.unitPrice) || 0) + return sum + (Number(item?.quantity || 0) * price) + }, 0), + }) const vatAmount = totalZAR * 0.15 const totalWithVat = totalZAR + vatAmount diff --git a/src/components/settings/UserManagement.tsx b/src/components/settings/UserManagement.tsx new file mode 100644 index 0000000..c1a435a --- /dev/null +++ b/src/components/settings/UserManagement.tsx @@ -0,0 +1,441 @@ +"use client"; + +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Loader2, UserPlus, Users, Check, X, Trash2, ShieldAlert } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { showSuccess, showError } from "@/utils/toast"; +import pb from "@/lib/pocketbase"; +import { useAuth } from "@/contexts/AuthContext"; + +/** + * A member of the team. The app has no roles - a member is either active + * (can sign in) or inactive (cannot sign in). + * + * Note: the `users` auth collection has no `created`/`updated` autodate + * fields, so members are ordered by email instead. + */ +type Member = { + id: string; + email: string; + name: string; + active: boolean; +}; + +/** Shape of a `users` record as returned by the PocketBase SDK. */ +type UserRecord = { + id: string; + email: string; + name?: string; + active?: boolean; +}; + +/** Error thrown by the PocketBase SDK on a failed request. */ +type PbError = { + response?: { + message?: string; + data?: Record; + }; +}; + +const addMemberSchema = z + .object({ + name: z.string().min(2, { message: "Name must be at least 2 characters." }), + email: z.string().email({ message: "Please enter a valid email address." }), + password: z.string().min(8, { message: "Password must be at least 8 characters." }), + confirmPassword: z.string(), + }) + .refine((values) => values.password === values.confirmPassword, { + message: "Passwords do not match.", + path: ["confirmPassword"], + }); + +type AddMemberValues = z.infer; + +export function UserManagement() { + const { user: currentUser } = useAuth(); + const [members, setMembers] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [addOpen, setAddOpen] = React.useState(false); + const [adding, setAdding] = React.useState(false); + const [busyId, setBusyId] = React.useState(null); + const [removeTarget, setRemoveTarget] = React.useState(null); + const [removing, setRemoving] = React.useState(false); + + const loadMembers = React.useCallback(async () => { + try { + const records = await pb.collection("users").getFullList({ sort: "email" }); + setMembers( + records.map((r) => { + const user = r as unknown as UserRecord; + return { + id: user.id, + email: user.email || "", + name: user.name || "", + active: user.active !== false, + }; + }), + ); + } catch (err) { + console.error("Error loading members:", err); + const pbErr = err as PbError; + showError(pbErr?.response?.message || "Failed to load members."); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + loadMembers(); + }, [loadMembers]); + + const form = useForm({ + resolver: zodResolver(addMemberSchema), + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + }, + }); + + const onAddMember = async (values: AddMemberValues) => { + setAdding(true); + try { + await pb.collection("users").create({ + email: values.email, + password: values.password, + passwordConfirm: values.confirmPassword, + name: values.name, + // A new member must be active, otherwise AuthContext refuses the + // sign-in ("Account is deactivated"). + active: true, + }); + showSuccess(`${values.name} can now sign in with ${values.email}.`); + form.reset(); + setAddOpen(false); + await loadMembers(); + } catch (err) { + console.error("Error adding member:", err); + const pbErr = err as PbError; + const message = + pbErr?.response?.data?.email?.message || + pbErr?.response?.message || + "Failed to add member."; + showError(message); + } finally { + setAdding(false); + } + }; + + const toggleActive = async (member: Member, nextActive: boolean) => { + setBusyId(member.id); + try { + await pb.collection("users").update(member.id, { active: nextActive }); + setMembers((prev) => + prev.map((m) => (m.id === member.id ? { ...m, active: nextActive } : m)), + ); + showSuccess( + nextActive + ? `${member.name || member.email} can now sign in.` + : `${member.name || member.email} can no longer sign in.`, + ); + } catch (err) { + console.error("Error updating member:", err); + const pbErr = err as PbError; + showError(pbErr?.response?.message || "Failed to update member."); + } finally { + setBusyId(null); + } + }; + + const removeMember = async () => { + if (!removeTarget) return; + setRemoving(true); + try { + await pb.collection("users").delete(removeTarget.id); + showSuccess(`${removeTarget.name || removeTarget.email} was removed.`); + setRemoveTarget(null); + await loadMembers(); + } catch (err) { + console.error("Error removing member:", err); + const pbErr = err as PbError; + showError(pbErr?.response?.message || "Failed to remove member."); + } finally { + setRemoving(false); + } + }; + + return ( +
+
+
+

Users

+

+ Anyone you add here can sign in to the app. Turn a member off to + block their access without deleting their history. +

+
+ + + + + + + Add User + + Create an account for a member of your team. Share the password + with them so they can sign in. + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + Minimum 8 characters. They can change it after signing in. + + + + )} + /> + ( + + Confirm Password + + + + + + )} + /> + + + +
+
+
+ + {loading ? ( +
+ + Loading users... +
+ ) : members.length === 0 ? ( +
+ +

No users yet

+

+ Add the first member to get your team started. +

+
+ ) : ( +
    + {members.map((member) => { + const isSelf = currentUser?.id === member.id; + const busy = busyId === member.id; + return ( +
  • +
    +
    +
    +

    + {member.name || member.email} +

    + {isSelf && ( + + You + + )} + + {member.active ? ( + <> + + Active + + ) : ( + <> + + Inactive + + )} + +
    +

    + {member.email} +

    +
    + +
    + + +
    +
    +
  • + ); + })} +
+ )} + +
+ +

+ This app has no roles. Every user you add can sign in and change any + data. Turn someone off instead of deleting them if you only want to + block their access. +

+
+ + !open && setRemoveTarget(null)} + > + + + Remove this user? + + {removeTarget?.name || removeTarget?.email} will lose access to + the app immediately. This cannot be undone - consider turning them + off instead. + + + + Cancel + + {removing ? "Removing..." : "Remove User"} + + + + +
+ ); +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 775c93a..e6bebc4 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -36,7 +36,7 @@ const DialogContent = React.forwardRef< { Book Out Item - + Book Out Item diff --git a/src/pages/Customers.tsx b/src/pages/Customers.tsx index 62f1b1f..ed96c7d 100644 --- a/src/pages/Customers.tsx +++ b/src/pages/Customers.tsx @@ -68,7 +68,7 @@ const Customers = () => { Add New Customer - + Add New Customer diff --git a/src/pages/PurchaseOrders.tsx b/src/pages/PurchaseOrders.tsx index 28e0d97..c1f2a6f 100644 --- a/src/pages/PurchaseOrders.tsx +++ b/src/pages/PurchaseOrders.tsx @@ -135,7 +135,7 @@ const PurchaseOrders = () => { New Purchase Order - + New Purchase Order diff --git a/src/pages/Quotes.tsx b/src/pages/Quotes.tsx index 77836ae..e8e2ebe 100644 --- a/src/pages/Quotes.tsx +++ b/src/pages/Quotes.tsx @@ -109,7 +109,7 @@ const Quotes = () => { New Quote - + New Quote Select a customer and add items to create a quote. diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 6baebcc..699ac01 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,7 +1,16 @@ -const Settings = () => ( -
-

Settings

-

Application settings will be configured here.

-
-); -export default Settings; \ No newline at end of file +import { UserManagement } from "@/components/settings/UserManagement"; + +const Settings = () => { + return ( +
+
+

Settings

+

+ Manage who can access this app. +

+
+ +
+ ); +}; +export default Settings; diff --git a/src/pages/Stock.tsx b/src/pages/Stock.tsx index 2decf24..8e2a314 100644 --- a/src/pages/Stock.tsx +++ b/src/pages/Stock.tsx @@ -67,7 +67,7 @@ const Stock = () => { Add New Item - + Add New Stock Item @@ -78,7 +78,10 @@ const Stock = () => {
-
+ {/* relative: the sr-only labels inside the table are position:absolute. + Without a positioned ancestor they escape this scroll container and + stretch the whole page, which makes Chrome zoom out on phones. */} +
{ Add New Supplier - + Add New Supplier