diff --git a/app/(clock)/clock/loading.tsx b/app/(clock)/clock/loading.tsx new file mode 100644 index 0000000..f7eb215 --- /dev/null +++ b/app/(clock)/clock/loading.tsx @@ -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
+
+
+

Loading…

+
; +} diff --git a/app/(dashboard)/running/oils/page.tsx b/app/(dashboard)/running/oils/page.tsx index a6427ec..56c5462 100644 --- a/app/(dashboard)/running/oils/page.tsx +++ b/app/(dashboard)/running/oils/page.tsx @@ -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)); diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 10e42c3..5e17b8d 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -29,7 +29,7 @@ export default async function SettingsPage() {

Your account

{user.name || "Mega Plant user"}

{user.email}

-

Everyone on the team has the same access to the dashboard and settings.

+

Full users reach the whole app. Clock-app-only users can sign in to /clock and nothing else.

diff --git a/app/actions/auth.ts b/app/actions/auth.ts index 53e04fe..b117346 100644 --- a/app/actions/auth.ts +++ b/app/actions/auth.ts @@ -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." diff --git a/app/api/clock/stock-out/route.ts b/app/api/clock/stock-out/route.ts index 0aa14c9..3350477 100644 --- a/app/api/clock/stock-out/route.ts +++ b/app/api/clock/stock-out/route.ts @@ -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); } diff --git a/app/api/oils/movements/[id]/photo/route.ts b/app/api/oils/movements/[id]/photo/route.ts new file mode 100644 index 0000000..92113ce --- /dev/null +++ b/app/api/oils/movements/[id]/photo/route.ts @@ -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 }); + } +} diff --git a/components/BookedOutTable.tsx b/components/BookedOutTable.tsx index 657830e..4bd720f 100644 --- a/components/BookedOutTable.tsx +++ b/components/BookedOutTable.tsx @@ -95,14 +95,16 @@ export function BookedOutTable({ log }: { log: OilLogRow[] }) {

) : (
- +
- + + + @@ -112,7 +114,9 @@ export function BookedOutTable({ log }: { log: OilLogRow[] }) { - + + + ))} @@ -121,7 +125,7 @@ export function BookedOutTable({ log }: { log: OilLogRow[] }) { -
When Product Litres VehicleBooked byOdometerBooked byMeter
{r.item} {fmt(r.litres)} {r.asset || "—"}{r.by || "—"}{r.odometer != null ? fmt(r.odometer) : "—"}{r.by || "—"}{r.photo ? View photo : "—"}
Total · {rows.length} booking{rows.length !== 1 && "s"} {fmt(rows.reduce((s, r) => s + r.litres, 0))} +
diff --git a/components/ClockBottomNav.tsx b/components/ClockBottomNav.tsx index 055aea2..d082ca3 100644 --- a/components/ClockBottomNav.tsx +++ b/components/ClockBottomNav.tsx @@ -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(null); + useEffect(() => setTapped(null), [path]); if (path.startsWith('/clock/login') || path.startsWith('/clock/change-password')) return null; return
-
-

Scanned on this device

- Attendance
- {recent.length===0 ?

Nobody scanned yet. The first scan of the day clocks someone IN; their next scan clocks them OUT.

- : } -
; } diff --git a/components/StockOut.tsx b/components/StockOut.tsx index a3c2155..de8677e 100644 --- a/components/StockOut.tsx +++ b/components/StockOut.tsx @@ -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 { + 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(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(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[] })

{message.text}

} {!item ? <> -
- {unknownScan &&

That QR is not an oil or diesel label ({unknownScan}). Scan the label on the drum or tank.

} +
+ {unknownScan &&

That QR is not a vehicle in the plant register or an oil / diesel label ({unknownScan}).

} :

{item.name}

@@ -74,7 +100,13 @@ export function StockOut({ items, assets }: { items: Item[]; assets: Asset[] }) {over &&

That is more than the {fmt(item.onHand)} L recorded. It will still be booked — check the stock level afterwards.

} - } diff --git a/components/auth/AuthForms.tsx b/components/auth/AuthForms.tsx index 22d994d..100ce25 100644 --- a/components/auth/AuthForms.tsx +++ b/components/auth/AuthForms.tsx @@ -64,6 +64,13 @@ export function AddUserForm() { setEmail(event.target.value)} autoComplete="off" required maxLength={254} className={inputClass} placeholder="person@company.com" />
+
+ + +
diff --git a/components/auth/UsersTable.tsx b/components/auth/UsersTable.tsx index 68195bf..ab07999 100644 --- a/components/auth/UsersTable.tsx +++ b/components/auth/UsersTable.tsx @@ -26,10 +26,10 @@ export function UsersTable({ users, currentUserId }: { users: ManagedUser[]; cur {state.success &&

{state.success}

}
- +
- + {users.map((user) => @@ -38,6 +38,15 @@ export function UsersTable({ users, currentUserId }: { users: ManagedUser[]; cur {user.id === currentUserId && You} +
Mega Plant users and their access status
UserEmailStatusActions
UserEmailAccessStatusActions
{user.email} + {user.id === currentUserId ? Full :
+ + +
} +
{user.active && !user.passwordChanged &&

First sign-in pending

} diff --git a/lib/auth-policy.ts b/lib/auth-policy.ts index 8d92237..9133bb0 100644 --- a/lib/auth-policy.ts +++ b/lib/auth-policy.ts @@ -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" : "/"; } diff --git a/lib/auth-service.ts b/lib/auth-service.ts index 9a9749a..dc7635b 100644 --- a/lib/auth-service.ts +++ b/lib/auth-service.ts @@ -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 { 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 { @@ -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 { +export async function addUser(name: string, email: string, access: UserAccess = "full"): Promise { 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 { + 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 { @@ -108,6 +124,7 @@ export async function listUsers(): Promise { 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), })); } diff --git a/lib/clock-api.ts b/lib/clock-api.ts index c47f79c..4f90b92 100644 --- a/lib/clock-api.ts +++ b/lib/clock-api.ts @@ -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; } diff --git a/lib/stock-out.ts b/lib/stock-out.ts index e571b76..8e8de95 100644 --- a/lib/stock-out.ts +++ b/lib/stock-out.ts @@ -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(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; +} diff --git a/proxy.ts b/proxy.ts index 160077f..11ff28a 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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(); diff --git a/scripts/setup-auth.mjs b/scripts/setup-auth.mjs index 1831a0e..b656f0d 100644 --- a/scripts/setup-auth.mjs +++ b/scripts/setup-auth.mjs @@ -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)"], }); diff --git a/scripts/setup-oil-collections.mjs b/scripts/setup-oil-collections.mjs index 9bd538a..ee80fe0 100644 --- a/scripts/setup-oil-collections.mjs +++ b/scripts/setup-oil-collections.mjs @@ -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 }, ]);