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