Clock app: vehicle-QR diesel book-out with meter photo; user access levels

- Scanning a vehicle QR (its register code, e.g. TLB04) books diesel from the main
  tank; litres, optional odometer and a required meter photo (stored protected,
  viewable from the Booked out table)
- Settings: users are Full or Clock-app-only; clock-only users are confined to /clock
- Clock app: tapped tab highlights at once plus a loading screen; remove the
  "scanned on this device" list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
David 2026-10-22 12:25:15 +02:00
parent 1dc1472841
commit 29a035a4f2
20 changed files with 216 additions and 62 deletions

View file

@ -0,0 +1,10 @@
import { RotateCcw } from 'lucide-react';
/** Shown instantly while a tab's page loads, so a tap on the bottom bar never looks dead. */
export default function ClockLoading() {
return <section className="plant-clock-page p-4 sm:p-6 max-w-3xl mx-auto" aria-busy="true">
<div className="h-4 w-24 rounded bg-border animate-pulse"/>
<div className="h-8 w-64 max-w-full rounded bg-border animate-pulse mt-3"/>
<div className="plant-camera mt-4 grid place-items-center"><p className="flex items-center gap-2 rounded-lg bg-[#003D5B]/90 px-4 py-2 text-sm text-white"><RotateCcw size={16} className="animate-spin"/>Loading…</p></div>
</section>;
}

View file

@ -31,6 +31,8 @@ export default async function OilsPage() {
litres: m.litres,
asset: assetCode.get((m as any).asset) || "",
by: (m as any).booked_by || "",
photo: !!(m as any).meter_photo,
odometer: (m as any).odometer ? Number((m as any).odometer) : null,
}))
.sort((a, b) => b.at.localeCompare(a.at));

View file

@ -29,7 +29,7 @@ export default async function SettingsPage() {
<div className="mb-5 flex items-center gap-3"><UserRound className="h-5 w-5 text-[#30638E]" /><h2 className="text-lg font-semibold">Your account</h2></div>
<p className="break-words text-base font-medium">{user.name || "Mega Plant user"}</p>
<p className="mt-1 break-all text-sm text-gray-500 dark:text-gray-400">{user.email}</p>
<p className="my-5 border-t border-gray-100 pt-5 text-sm leading-relaxed text-gray-500 dark:border-border dark:text-gray-400">Everyone on the team has the same access to the dashboard and settings.</p>
<p className="my-5 border-t border-gray-100 pt-5 text-sm leading-relaxed text-gray-500 dark:border-border dark:text-gray-400">Full users reach the whole app. Clock-app-only users can sign in to /clock and nothing else.</p>
<LogoutButton />
</section>
</div>

View file

@ -6,8 +6,8 @@ import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { ClientResponseError } from "pocketbase";
import { getSession, requireUser } from "@/lib/auth";
import { addUser, authenticateUser, changeUserPassword, deleteUser, InactiveUserError, setUserActive } from "@/lib/auth-service";
import { SESSION_COOKIE, authDestination, validateLogin, validateNewPassword, validateNewUser } from "@/lib/auth-policy";
import { addUser, authenticateUser, changeUserPassword, deleteUser, InactiveUserError, setUserAccess, setUserActive } from "@/lib/auth-service";
import { SESSION_COOKIE, authDestination, parseAccess, validateLogin, validateNewPassword, validateNewUser } from "@/lib/auth-policy";
export type FormState = { error?: string; success?: string };
const value = (form: FormData, key: string) => String(form.get(key) ?? "");
@ -61,9 +61,10 @@ export async function addUserAction(_state: FormState, form: FormData): Promise<
const error = validateNewUser(name, email);
if (error) return { error };
try {
const user = await addUser(name, email);
const access = parseAccess(value(form, "access"));
const user = await addUser(name, email, access);
revalidatePath("/settings");
return { success: `${user.name} has been added. They can sign in with ${user.email} and the starter password.` };
return { success: `${user.name} has been added${access === "clock" ? " as a clock-app-only user" : ""}. They can sign in${access === "clock" ? " at /clock" : ""} with ${user.email} and the starter password.` };
} catch (error) {
const details = error instanceof ClientResponseError ? error.response?.data : null;
return { error: details?.email?.code === "validation_not_unique"
@ -77,14 +78,15 @@ export async function manageUserAction(_state: FormState, form: FormData): Promi
const userId = value(form, "userId");
const operation = value(form, "operation");
if (userId === actor.id) return { error: "You can't disable or delete your own account." };
if (!/^[a-z0-9]{15}$/.test(userId) || !["activate", "deactivate", "delete"].includes(operation)) {
if (!/^[a-z0-9]{15}$/.test(userId) || !["activate", "deactivate", "delete", "full", "clock"].includes(operation)) {
return { error: "Choose a valid user and action." };
}
try {
if (operation === "delete") await deleteUser(actor.id, userId);
else if (operation === "full" || operation === "clock") await setUserAccess(actor.id, userId, operation);
else await setUserActive(actor.id, userId, operation === "activate");
revalidatePath("/settings");
return { success: operation === "delete" ? "User deleted." : operation === "activate" ? "User is active again." : "User is inactive and can no longer access the app." };
return { success: operation === "full" ? "User now has full access." : operation === "clock" ? "User is now limited to the clock app." : operation === "delete" ? "User deleted." : operation === "activate" ? "User is active again." : "User is inactive and can no longer access the app." };
} catch (error) {
return { error: error instanceof ClientResponseError && error.status === 404
? "This user no longer exists. Refresh the list."

View file

@ -8,11 +8,20 @@ import { MAX_BOOK_OUT_LITRES } from '@/lib/stock-out';
// Stock is the sum of movements, so this only ever appends.
export async function POST(request: Request) {
try {
const user = await clockAccess(request);
const user = await clockAccess(request, { multipart: true });
if (user instanceof Response) return user;
let body: { item_id?: string; asset_id?: string; litres?: unknown };
try { body = await request.json(); } catch { return Response.json({ error: 'Supply valid JSON.' }, { status: 400 }); }
let form: FormData;
try { form = await request.formData(); } catch { return Response.json({ error: 'Supply the form with a meter photo.' }, { status: 400 }); }
const body = { item_id: String(form.get('item_id') || ''), asset_id: String(form.get('asset_id') || ''), litres: form.get('litres') };
const photo = form.get('meter_photo');
const odoRaw = String(form.get('odometer') || '').trim();
const odometer = odoRaw === '' ? null : Number(odoRaw);
if (odometer !== null && (!Number.isFinite(odometer) || odometer < 0 || odometer > 99999999))
return Response.json({ error: 'The odometer reading must be a number.' }, { status: 400 });
const litres = Math.round(Number(body.litres) * 100) / 100;
if (!(photo instanceof File) || photo.size === 0) return Response.json({ error: 'Take a photo of the meter first.' }, { status: 400 });
if (!photo.type.startsWith('image/')) return Response.json({ error: 'The meter photo must be an image.' }, { status: 400 });
if (photo.size > 10 * 1024 * 1024) return Response.json({ error: 'The photo is too large (10 MB max).' }, { status: 413 });
if (!body.item_id || !body.asset_id) return Response.json({ error: 'Choose the product and the vehicle.' }, { status: 400 });
if (!Number.isFinite(litres) || litres <= 0 || litres > MAX_BOOK_OUT_LITRES)
return Response.json({ error: `Litres must be more than 0 and at most ${MAX_BOOK_OUT_LITRES}.` }, { status: 400 });
@ -26,11 +35,13 @@ export async function POST(request: Request) {
if (!asset) return Response.json({ error: 'That vehicle is not in the plant register.' }, { status: 404 });
const who = String(user.user.name || user.user.email || user.user.id);
const move = await pb.collection('oil_stock_movements').create({
item: item.id, asset: asset.id, kind: 'used', litres,
occurred_on: new Date().toISOString(), // full instant: `created` is not auto-filled here
note: `Booked out to ${asset.code}`, booked_by: who,
});
const data = new FormData();
data.set('item', item.id); data.set('asset', asset.id); data.set('kind', 'used'); data.set('litres', String(litres));
data.set('occurred_on', new Date().toISOString()); // full instant: `created` is not auto-filled here
if (odometer !== null) data.set('odometer', String(odometer));
data.set('note', `Booked out to ${asset.code}`); data.set('booked_by', who);
data.set('meter_photo', photo, photo.name || 'meter.jpg');
const move = await pb.collection('oil_stock_movements').create(data);
revalidatePath('/running/oils');
return clockJson({ id: move.id, item: item.name, asset: asset.code, litres, at: move.occurred_on });
} catch (e) { return clockFailure(e); }

View file

@ -0,0 +1,26 @@
import { serverAuth } from "@/lib/pb";
export const dynamic = "force-dynamic";
// GET /api/oils/movements/:id/photo — the meter photo taken when litres were booked out.
// Proxied with the server credentials (the field is protected), so only a signed-in user
// can open it; the proxy middleware already requires a session for /api.
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
const pb = await serverAuth();
const move = await pb.collection("oil_stock_movements").getOne(id);
if (!move.meter_photo) return Response.json({ ok: false, error: "No photo attached" }, { status: 404 });
const token = await pb.files.getToken();
const res = await fetch(pb.files.getURL(move, move.meter_photo, { token }));
if (!res.ok) return Response.json({ ok: false, error: "Photo could not be read" }, { status: 502 });
return new Response(res.body, {
headers: {
"Content-Type": res.headers.get("Content-Type") ?? "image/jpeg",
"Cache-Control": "private, max-age=3600",
},
});
} catch (e: any) {
return Response.json({ ok: false, error: e?.message || "Could not load photo" }, { status: 500 });
}
}

View file

@ -95,14 +95,16 @@ export function BookedOutTable({ log }: { log: OilLogRow[] }) {
</p>
) : (
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<table className="w-full text-sm min-w-[560px]">
<table className="w-full text-sm min-w-[760px]">
<thead>
<tr className="text-left text-xs text-gray-500 dark:text-gray-400">
<th className="sticky left-0 bg-white dark:bg-surface py-2 pr-3 font-medium">When</th>
<th className="py-2 pr-3 font-medium">Product</th>
<th className="py-2 pr-3 font-medium text-right">Litres</th>
<th className="py-2 pr-3 font-medium">Vehicle</th>
<th className="py-2 font-medium">Booked by</th>
<th className="py-2 pr-3 font-medium text-right">Odometer</th>
<th className="py-2 pr-3 font-medium">Booked by</th>
<th className="py-2 font-medium">Meter</th>
</tr>
</thead>
<tbody>
@ -112,7 +114,9 @@ export function BookedOutTable({ log }: { log: OilLogRow[] }) {
<td className="py-2 pr-3">{r.item}</td>
<td className="py-2 pr-3 text-right tabular-nums">{fmt(r.litres)}</td>
<td className="py-2 pr-3 font-mono">{r.asset || "—"}</td>
<td className="py-2">{r.by || "—"}</td>
<td className="py-2 pr-3 text-right tabular-nums">{r.odometer != null ? fmt(r.odometer) : "—"}</td>
<td className="py-2 pr-3">{r.by || "—"}</td>
<td className="py-2">{r.photo ? <a href={`/api/oils/movements/${r.id}/photo`} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-[44px] items-center text-[#30638E] underline">View photo</a> : "—"}</td>
</tr>
))}
</tbody>
@ -121,7 +125,7 @@ export function BookedOutTable({ log }: { log: OilLogRow[] }) {
<td className="sticky left-0 bg-white dark:bg-surface py-2 pr-3">Total · {rows.length} booking{rows.length !== 1 && "s"}</td>
<td />
<td className="py-2 pr-3 text-right tabular-nums">{fmt(rows.reduce((s, r) => s + r.litres, 0))}</td>
<td colSpan={2} />
<td colSpan={4} />
</tr>
</tfoot>
</table>

View file

@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import { ClipboardList, Droplets, Users } from 'lucide-react';
const TABS = [
@ -12,12 +13,17 @@ const TABS = [
/** Mobile-app style tab bar. Hidden on the sign-in / password screens. */
export function ClockBottomNav() {
const path = usePathname();
// Highlight the tapped tab at once: the next page takes a moment to load and an
// unchanged bar reads as "the tap did not register".
const [tapped, setTapped] = useState<string | null>(null);
useEffect(() => setTapped(null), [path]);
if (path.startsWith('/clock/login') || path.startsWith('/clock/change-password')) return null;
return <nav aria-label="Plant app" className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface pb-[env(safe-area-inset-bottom)]">
<ul className="mx-auto max-w-3xl grid grid-cols-3">
{TABS.map(({ href, label, Icon }) => {
const active = href === '/clock' ? path === '/clock' : path.startsWith(href);
return <li key={href}><Link href={href} aria-current={active ? 'page' : undefined}
const here = tapped ?? path;
const active = href === '/clock' ? here === '/clock' : here.startsWith(href);
return <li key={href}><Link href={href} onClick={() => setTapped(href)} aria-current={active ? 'page' : undefined}
className={`flex min-h-[56px] flex-col items-center justify-center gap-0.5 text-xs font-medium ${active ? 'text-[#30638E]' : 'text-text-muted'}`}>
<Icon size={22} strokeWidth={active ? 2.4 : 1.8}/>{label}</Link></li>;
})}

View file

@ -8,7 +8,7 @@ import type { OilStock } from "@/lib/oil";
import { oilQrPayload, isTank } from "@/lib/oil";
import { Download } from "lucide-react";
export type OilLogRow = { id: string; at: string; item: string; litres: number; asset: string; by: string };
export type OilLogRow = { id: string; at: string; item: string; litres: number; asset: string; by: string; photo: boolean; odometer: number | null };
export { downloadPdf } from "@/lib/qr-pdf";

View file

@ -1,7 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { AlertTriangle, ArrowRight, CheckCircle2, LogIn, LogOut, RotateCcw, Users } from 'lucide-react';
import { AlertTriangle, ArrowRight, CheckCircle2, LogIn, LogOut, RotateCcw } from 'lucide-react';
import { clockTime, duration } from '@/lib/clock-display';
import { clockRequestId } from '@/lib/clock-request';
import { BadgeCamera } from './BadgeCamera';
@ -23,7 +22,6 @@ export function PlantClock() {
const [code,setCode]=useState('');
const [busy,setBusy]=useState(false);
const [banner,setBanner]=useState<Banner|null>(null);
const [recent,setRecent]=useState<Saved[]>([]);
const [now,setNow]=useState('');
const gate=useRef(false);
const lastBadge=useRef({value:'',until:0});
@ -43,7 +41,7 @@ export function PlantClock() {
const data=await response.json().catch(()=>({}));
if(response.ok) {
const scan=data as Saved;
show({kind:'saved',scan});setRecent(list=>[scan,...list.filter(s=>s.id!==scan.id)].slice(0,8));setCode('');
show({kind:'saved',scan});setCode('');
navigator.vibrate?.(120);
} else if(data.repeat) {
show({kind:'repeat',...data.repeat});setCode('');
@ -95,14 +93,5 @@ export function PlantClock() {
<button className="plant-clock-button plant-clock-primary shrink-0" disabled={!code.trim()||busy} type="submit">Clock<ArrowRight size={16}/></button></div>
</form>
<div className="plant-clock-panel mt-5 p-4 sm:p-5">
<div className="flex items-center justify-between gap-3"><h2 className="text-base font-semibold">Scanned on this device</h2>
<Link href="/attendance" target="_blank" rel="noopener noreferrer" className="plant-clock-button plant-clock-secondary"><Users size={16}/>Attendance</Link></div>
{recent.length===0 ? <p className="text-sm text-text-muted mt-3">Nobody scanned yet. The first scan of the day clocks someone IN; their next scan clocks them OUT.</p>
: <ul className="divide-y divide-border mt-2">{recent.map(s=><li key={s.id} className="py-3 flex items-center gap-3">
<span className={`plant-clock-status shrink-0 ${s.direction==='in'?'plant-clock-status-in':''}`}>{s.direction.toUpperCase()}</span>
<span className="min-w-0 flex-1"><span className="block text-sm font-medium break-words">{s.employeeName}</span><span className="block text-xs text-text-muted">{s.employeeNo}{s.direction==='out'?` · ${duration(s.sessionMinutes)}`:''}</span></span>
<span className="text-sm tabular-nums shrink-0">{clockTime(s.at)}</span></li>)}</ul>}
</div>
</section>;
}

View file

@ -1,9 +1,22 @@
'use client';
import { useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, CheckCircle2, Droplets, X } from 'lucide-react';
import { AlertTriangle, Camera, CheckCircle2, Droplets, X } from 'lucide-react';
import { BadgeCamera } from './BadgeCamera';
import { oilCodeFromQr } from '@/lib/stock-out';
import { assetFromQr, oilCodeFromQr } from '@/lib/stock-out';
/** Phone photos are 4–8 MB; shrink to ~1600px JPEG so a yard with poor signal can still upload. */
async function shrink(file: File): Promise<File> {
try {
const bmp = await createImageBitmap(file);
const scale = Math.min(1, 1600 / Math.max(bmp.width, bmp.height));
const canvas = document.createElement('canvas');
canvas.width = Math.round(bmp.width * scale); canvas.height = Math.round(bmp.height * scale);
canvas.getContext('2d')!.drawImage(bmp, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>(r => canvas.toBlob(r, 'image/jpeg', 0.8));
return blob ? new File([blob], 'meter.jpg', { type: 'image/jpeg' }) : file;
} catch { return file; }
}
type Item = { id: string; code: string; name: string; colour: string; onHand: number; tank: boolean };
type Asset = { id: string; code: string; name: string };
@ -19,10 +32,18 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [unknownScan, setUnknownScan] = useState('');
const [odometer, setOdometer] = useState('');
const [photo, setPhoto] = useState<File | null>(null);
const [preview, setPreview] = useState('');
const lastScan = useRef({ value: '', until: 0 });
function pick(next: Item | null) {
setItem(next); setLitres(''); setAssetId(''); setUnknownScan('');
setItem(next); setLitres(''); setAssetId(''); setUnknownScan(''); setPhoto(null); setPreview(''); setOdometer('');
}
async function takePhoto(file: File | undefined) {
if (!file) return;
const small = await shrink(file);
setPhoto(small); setPreview(URL.createObjectURL(small));
}
function onDecode(raw: string) {
if (item || busy) return;
@ -30,16 +51,21 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
lastScan.current = { value: raw, until: Date.now() + 4000 };
const code = oilCodeFromQr(raw);
const found = code && items.find(i => i.code.toUpperCase() === code.toUpperCase());
if (found) { setMessage(null); pick(found); navigator.vibrate?.(120); }
else setUnknownScan(raw.slice(0, 60));
if (found) { setMessage(null); pick(found); navigator.vibrate?.(120); return; }
// A vehicle's own QR books DIESEL from the main tank to that vehicle.
const vehicle = assetFromQr(raw, assets);
const tank = items.find(i => i.tank);
if (vehicle && tank) { setMessage(null); pick(tank); setAssetId(vehicle.id); navigator.vibrate?.(120); return; }
setUnknownScan(raw.slice(0, 60));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!item || busy) return;
setBusy(true); setMessage(null);
try {
const res = await fetch('/api/clock/stock-out', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: item.id, asset_id: assetId, litres: litres.trim() }) });
const body = new FormData();
body.set('item_id', item.id); body.set('asset_id', assetId); body.set('litres', litres.trim()); body.set('meter_photo', photo!); if (odometer.trim()) body.set('odometer', odometer.trim());
const res = await fetch('/api/clock/stock-out', { method: 'POST', body });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Could not save.');
setMessage({ ok: true, text: `${fmt(data.litres)} L of ${data.item} booked to ${data.asset}.` });
@ -61,8 +87,8 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
<p className="text-base font-semibold break-words">{message.text}</p></div>}
{!item ? <>
<div className="mt-4"><BadgeCamera onDecode={onDecode} paused={busy} hint="Scan the QR on the drum or tank"/></div>
{unknownScan && <p role="alert" className="plant-clock-warning mt-3">That QR is not an oil or diesel label ({unknownScan}). Scan the label on the drum or tank.</p>}
<div className="mt-4"><BadgeCamera onDecode={onDecode} paused={busy} hint="Scan the vehicle QR (or a drum / tank label)"/></div>
{unknownScan && <p role="alert" className="plant-clock-warning mt-3">That QR is not a vehicle in the plant register or an oil / diesel label ({unknownScan}).</p>}
</> : <form onSubmit={submit} className="plant-clock-panel mt-4 p-4 sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0"><div className="flex items-center gap-2"><Droplets size={20} style={{ color: item.colour }}/><h2 className="text-lg font-semibold break-words">{item.name}</h2></div>
@ -74,7 +100,13 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] })
{over && <p className="plant-clock-warning mt-2">That is more than the {fmt(item.onHand)} L recorded. It will still be booked — check the stock level afterwards.</p>}
<label htmlFor="vehicle-search" className="block text-sm font-medium mt-4">Which vehicle is it for?</label>
<VehiclePicker assets={assets} value={assetId} onChange={setAssetId}/>
<button className="plant-clock-button plant-clock-primary w-full mt-5" type="submit" disabled={busy || !(entered > 0) || !assetId}>
<label htmlFor="odometer" className="block text-sm font-medium mt-4">Odometer / hour meter <span className="font-normal text-text-muted">(if it has one)</span></label>
<input id="odometer" className="plant-clock-input mt-1 tabular-nums" type="number" inputMode="decimal" min="0" step="any" value={odometer} onChange={e => setOdometer(e.target.value)} placeholder="Current reading"/>
<p className="block text-sm font-medium mt-4">Photo of the meter</p>
<label htmlFor="meter-photo" className="plant-clock-button plant-clock-secondary w-full mt-1 cursor-pointer"><Camera size={18}/>{photo ? 'Retake photo' : 'Take photo'}</label>
<input id="meter-photo" type="file" accept="image/*" capture="environment" className="sr-only" onChange={e => { void takePhoto(e.target.files?.[0]); e.target.value = ''; }}/>
{preview && <img src={preview} alt="Meter photo to upload" className="mt-2 max-h-56 w-full rounded-xl object-contain bg-bg border border-border"/>}
<button className="plant-clock-button plant-clock-primary w-full mt-5" type="submit" disabled={busy || !(entered > 0) || !assetId || !photo}>
{busy ? 'Saving…' : 'Book out'}</button>
</form>}

View file

@ -64,6 +64,13 @@ export function AddUserForm() {
<label htmlFor="email" className="mb-2 block text-sm font-medium">Email address</label>
<input id="email" name="email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} autoComplete="off" required maxLength={254} className={inputClass} placeholder="person@company.com" />
</div>
<div>
<label htmlFor="access" className="mb-2 block text-sm font-medium">Access</label>
<select id="access" name="access" defaultValue="full" className={inputClass}>
<option value="full">Full — main app and clock app</option>
<option value="clock">Clock app only</option>
</select>
</div>
<div>
<label htmlFor="starter" className="mb-2 block text-sm font-medium">Starter password</label>
<input id="starter" value={STARTER_PASSWORD} readOnly className={`${inputClass} font-mono bg-gray-50`} aria-describedby="starter-hint" />

View file

@ -26,10 +26,10 @@ export function UsersTable({ users, currentUserId }: { users: ManagedUser[]; cur
{state.success && <p role="status" className="mx-5 mb-4 rounded-lg bg-[#00798C]/10 p-3 text-sm text-accent-ink sm:mx-7">{state.success}</p>}
<div className="relative">
<div className="overflow-x-auto" style={{ WebkitOverflowScrolling: "touch" }}>
<table className="w-full min-w-[700px] text-left text-sm">
<table className="w-full min-w-[820px] text-left text-sm">
<caption className="sr-only">Mega Plant users and their access status</caption>
<thead className="border-y border-border bg-surface-muted text-xs uppercase tracking-wide text-text-muted">
<tr><th scope="col" className="sticky left-0 z-10 bg-surface-muted px-5 py-4 font-medium sm:px-7">User</th><th scope="col" className="px-4 py-4 font-medium">Email</th><th scope="col" className="px-4 py-4 font-medium">Status</th><th scope="col" className="px-5 py-4 font-medium sm:px-7">Actions</th></tr>
<tr><th scope="col" className="sticky left-0 z-10 bg-surface-muted px-5 py-4 font-medium sm:px-7">User</th><th scope="col" className="px-4 py-4 font-medium">Email</th><th scope="col" className="px-4 py-4 font-medium">Access</th><th scope="col" className="px-4 py-4 font-medium">Status</th><th scope="col" className="px-5 py-4 font-medium sm:px-7">Actions</th></tr>
</thead>
<tbody className="divide-y divide-border">
{users.map((user) => <tr key={user.id}>
@ -38,6 +38,15 @@ export function UsersTable({ users, currentUserId }: { users: ManagedUser[]; cur
{user.id === currentUserId && <span className="mt-1 block pl-6 text-xs text-text-muted">You</span>}
</td>
<td className="max-w-64 break-all px-4 py-4 text-text-muted">{user.email}</td>
<td className="px-4 py-4 whitespace-nowrap">
{user.id === currentUserId ? <span className="text-sm">Full</span> : <form action={action}>
<input type="hidden" name="userId" value={user.id} />
<select name="operation" defaultValue={user.access} disabled={pending} aria-label={`Access for ${user.name || user.email}`}
onChange={(e) => e.currentTarget.form?.requestSubmit()} className="min-h-11 rounded-lg border border-border bg-surface px-2 text-sm">
<option value="full">Full</option><option value="clock">Clock app only</option>
</select>
</form>}
</td>
<td className="px-4 py-4">
<span className={`inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium ${user.active ? "bg-[#00798C]/10 text-accent-ink" : "bg-gray-100 text-text-muted dark:bg-white/5"}`}><span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${user.active ? "bg-[#00798C]" : "bg-gray-400"}`} />{user.active ? "Active" : "Inactive"}</span>
{user.active && !user.passwordChanged && <p className="mt-1 text-xs text-text-muted">First sign-in pending</p>}

View file

@ -21,6 +21,14 @@ export function validateNewUser(name: string, email: string): string | null {
return null;
}
export function authDestination(session: { passwordChanged: boolean } | null): string {
return !session ? "/login" : session.passwordChanged ? "/" : "/change-password";
/** "full" = the main app (and the clock app); "clock" = the Plant Clock app only. */
export type UserAccess = "full" | "clock";
export const USER_ACCESS: UserAccess[] = ["full", "clock"];
export const parseAccess = (value: unknown): UserAccess => (value === "clock" ? "clock" : "full");
export function authDestination(session: { passwordChanged: boolean; access?: UserAccess } | null): string {
if (!session) return "/login";
if (!session.passwordChanged) return "/change-password";
// A clock-only user has no home in the main app: send them to the clock.
return session.access === "clock" ? "/clock" : "/";
}

View file

@ -1,9 +1,9 @@
// Server-only consumers: auth.ts, server actions and proxy.ts. Never import in a client component.
import PocketBase, { ClientResponseError, type RecordModel } from "pocketbase";
import { STARTER_PASSWORD, validateLogin, validateNewPassword, validateNewUser } from "./auth-policy";
import { STARTER_PASSWORD, parseAccess, type UserAccess, validateLogin, validateNewPassword, validateNewUser } from "./auth-policy";
export type AuthSession = { pb: PocketBase; user: RecordModel; passwordChanged: boolean };
export type ManagedUser = { id: string; name: string; email: string; active: boolean; passwordChanged: boolean };
export type AuthSession = { pb: PocketBase; user: RecordModel; passwordChanged: boolean; access: UserAccess };
export type ManagedUser = { id: string; name: string; email: string; active: boolean; passwordChanged: boolean; access: UserAccess };
export class InactiveUserError extends Error {
constructor() { super("This account is inactive. Contact your team to restore access."); }
}
@ -42,7 +42,7 @@ async function session(pb: PocketBase): Promise<AuthSession> {
if (!user || user.collectionName !== "users") throw new Error("A user account is required.");
const state = await onboarding(user.id);
if (state?.disabled === true) throw new InactiveUserError();
return { pb, user: user as RecordModel, passwordChanged: state?.password_changed === true };
return { pb, user: user as RecordModel, passwordChanged: state?.password_changed === true, access: parseAccess(state?.access) };
}
export async function authenticateUser(email: string, password: string): Promise<AuthSession> {
@ -87,14 +87,30 @@ export async function changeUserPassword(current: AuthSession, password: string)
return { ...updated, passwordChanged: true };
}
export async function addUser(name: string, email: string): Promise<RecordModel> {
export async function addUser(name: string, email: string, access: UserAccess = "full"): Promise<RecordModel> {
const error = validateNewUser(name, email);
if (error) throw new Error(error);
const pb = await adminClient();
return pb.collection("users").create({
const user = await pb.collection("users").create({
name: name.trim(), email: email.trim().toLowerCase(),
password: STARTER_PASSWORD, passwordConfirm: STARTER_PASSWORD,
});
if (access === "clock") {
// Roll the account back if the restriction cannot be stored: a "clock only" user who
// silently got full access would be worse than no user.
try { await pb.collection("user_onboarding").create({ user: user.id, password_changed: false, access }); }
catch (e) { await pb.collection("users").delete(user.id).catch(() => {}); throw e; }
}
return user;
}
export async function setUserAccess(actorId: string, userId: string, access: UserAccess): Promise<void> {
validateManagedUser(actorId, userId);
const pb = await adminClient();
await pb.collection("users").getOne(userId);
const state = await onboarding(userId);
if (state) await pb.collection("user_onboarding").update(state.id, { access });
else await pb.collection("user_onboarding").create({ user: userId, access, password_changed: false });
}
export async function listUsers(): Promise<ManagedUser[]> {
@ -108,6 +124,7 @@ export async function listUsers(): Promise<ManagedUser[]> {
id: user.id, name: typeof user.name === "string" ? user.name : "", email: user.email,
active: byUser.get(user.id)?.disabled !== true,
passwordChanged: byUser.get(user.id)?.password_changed === true,
access: parseAccess(byUser.get(user.id)?.access),
}));
}

View file

@ -3,13 +3,14 @@ import { getSession } from './auth';
import { ClockError } from './clock';
import { clockOriginAllowed } from './clock-origin';
export async function clockAccess(request:Request) {
export async function clockAccess(request:Request,options:{multipart?:boolean}={}) {
const session=await getSession();
if(!session)return Response.json({error:'Please sign in.'},{status:401});
if(!session.passwordChanged)return Response.json({error:'Change your starter password first.'},{status:403});
if(request.method==='POST') {
if(!clockOriginAllowed(request.headers))return Response.json({error:'Request origin is not allowed.'},{status:403});
if(!request.headers.get('content-type')?.startsWith('application/json'))return Response.json({error:'Supply JSON.'},{status:415});
const type=request.headers.get('content-type')||'';
if(!type.startsWith('application/json') && !(options.multipart && type.startsWith('multipart/form-data')))return Response.json({error:'Supply JSON.'},{status:415});
}
return session;
}

View file

@ -1,3 +1,5 @@
import { normaliseCostCode } from "./cost-code";
/** The oil/diesel drum QR reads "Diesel | FUEL-DSL" — name, then item code. */
export function oilCodeFromQr(raw: string): string | null {
const text = (raw || "").trim();
@ -7,3 +9,22 @@ export function oilCodeFromQr(raw: string): string | null {
}
export const MAX_BOOK_OUT_LITRES = 5000;
/**
* Which vehicle does a scanned QR name? The register code is the join, compared the way
* purchase orders are ("SS-02" = "SS02"). Tolerant of how the sticker was printed: the bare
* code, "Name | CODE", or a link ending in the code. Never a fuzzy guess — no match, no vehicle.
*/
export function assetFromQr<T extends { code: string }>(raw: string, assets: T[]): T | null {
const text = (raw || "").trim();
if (!text || text.length > 300) return null;
const byCode = new Map(assets.map((a) => [normaliseCostCode(a.code), a]));
const whole = byCode.get(normaliseCostCode(text));
if (whole) return whole;
// Try each |, /, space, comma, colon or ? separated piece, last first (the code usually trails).
for (const part of text.split(/[|/\s,:;?=&#]+/).reverse()) {
const hit = part && byCode.get(normaliseCostCode(part));
if (hit) return hit;
}
return null;
}

View file

@ -14,12 +14,12 @@ export async function proxy(request: NextRequest) {
? NextResponse.json({ error: "Account service is temporarily unavailable." }, { status: 503 })
: new NextResponse("Account service is temporarily unavailable. Please reload to try again.", { status: 503, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" } });
}
const clockPath=path==="/clock"||path.startsWith("/clock/");
const clockPath=path==="/clock"||path.startsWith("/clock/")||path.startsWith("/api/clock/");
const destination = clockPath?clockAuthDestination(session):authDestination(session);
// Logout is a server action and remains available during first-login onboarding.
if ((path === "/change-password" || path === "/clock/change-password") && session) return NextResponse.next();
if (destination !== (clockPath?"/clock":"/")) {
if (path.startsWith("/api/")) return NextResponse.json({ error: session ? "Change your starter password first." : "Please sign in." }, { status: session ? 403 : 401 });
if (path.startsWith("/api/")) return NextResponse.json({ error: !session ? "Please sign in." : session.passwordChanged ? "This account only has access to the clock app." : "Change your starter password first." }, { status: session ? 403 : 401 });
return NextResponse.redirect(new URL(destination, request.url));
}
const response = NextResponse.next();

View file

@ -16,9 +16,13 @@ if (existing) {
}
const disabled = fields.find((field) => field.name === "disabled");
if (disabled && disabled.type !== "bool") throw new Error("user_onboarding.disabled must be a boolean.");
if (!disabled) {
await pb.collections.update(existing.id, { fields: [...fields, { name: "disabled", type: "bool" }] });
console.log("Added private access status. Existing users remain active.");
const add = [];
if (!disabled) add.push({ name: "disabled", type: "bool" });
// "full" (or empty) = main app + clock; "clock" = Plant Clock app only.
if (!fields.some((f) => f.name === "access")) add.push({ name: "access", type: "text" });
if (add.length) {
await pb.collections.update(existing.id, { fields: [...fields, ...add] });
console.log(`Added ${add.map((f) => f.name).join(", ")}. Existing users remain active with full access.`);
} else console.log("Private user_onboarding collection already configured. No changes made.");
} else {
const users = await pb.collections.getOne("users");
@ -29,6 +33,7 @@ if (existing) {
{ name: "user", type: "relation", collectionId: users.id, required: true, maxSelect: 1, cascadeDelete: true },
{ name: "password_changed", type: "bool" },
{ name: "disabled", type: "bool" },
{ name: "access", type: "text" },
],
indexes: ["CREATE UNIQUE INDEX idx_user_onboarding_user ON user_onboarding (user)"],
});

View file

@ -140,6 +140,10 @@ await ensureCollection("oil_stock_movements", [
// Not cascadeDelete — removing a machine must not erase the record of oil that was drawn.
{ name: "asset", type: "relation", collectionId: assetsId, minSelect: 0, maxSelect: 1, cascadeDelete: false },
{ name: "booked_by", type: "text" },
// Odometer (km) or hour-meter reading the driver typed in. Optional: not every machine has one.
{ name: "odometer", type: "number" },
// Photo of the vehicle's meter at the time of fuelling, for verification.
{ name: "meter_photo", type: "file", maxSelect: 1, maxSize: 10485760, mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"], protected: true },
{ name: "created", type: "date", onCreate: true, presentable: false },
{ name: "updated", type: "date", onUpdate: true, presentable: false },
]);