@@ -38,6 +38,15 @@ export function UsersTable({ users, currentUserId }: { users: ManagedUser[]; cur
{user.id === currentUserId && You}
| {user.email} |
+
+ {user.id === currentUserId ? Full : }
+ |
{user.active ? "Active" : "Inactive"}
{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 },
]);
|