mega-plant/lib/auth-service.ts
David 8f3a74cf6e Job cards oil path, Fuel tab diesel-only, Mechanic role, Plant Register QR column
- Job cards scanner: oil QR books litres + vehicle (no odo/photo), bundled onto the
  vehicle's job card (open job now, or the next job started the same day)
- Fuel tab is diesel only; /api/clock/oil-out added
- Users: Full / Clock-in user / Mechanic; mechanics see only Fuel + Job cards
- Plant Register: QR code column (double-click to set), kept across re-seeds and
  used when matching scans

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:44:01 +02:00

148 lines
6.8 KiB
TypeScript

// 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, parseAccess, type UserAccess, validateLogin, validateNewPassword, validateNewUser } from "./auth-policy";
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."); }
}
function client() {
const url = process.env.NEXT_PUBLIC_PB_URL;
if (!url) throw new Error("PocketBase URL is not configured.");
const pb = new PocketBase(url);
pb.autoCancellation(false);
pb.beforeSend = (url, options) => ({ url, options: { ...options, signal: AbortSignal.timeout(15000) } });
return pb;
}
let adminPromise: Promise<PocketBase> | undefined;
async function adminClient(): Promise<PocketBase> {
if (adminPromise && (await adminPromise).authStore.isValid) return adminPromise;
adminPromise = (async () => {
if (!process.env.PB_ADMIN || !process.env.PB_PASS) throw new Error("PocketBase server credentials are not configured.");
const pb = client();
await pb.collection("_superusers").authWithPassword(process.env.PB_ADMIN, process.env.PB_PASS);
return pb;
})();
try { return await adminPromise; } catch (error) { adminPromise = undefined; throw error; }
}
async function onboarding(userId: string) {
const pb = await adminClient();
const result = await pb.collection("user_onboarding").getList(1, 1, {
filter: pb.filter("user = {:userId}", { userId }),
});
return result.items[0] ?? null;
}
async function session(pb: PocketBase): Promise<AuthSession> {
const user = pb.authStore.record;
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, access: parseAccess(state?.access) };
}
export async function authenticateUser(email: string, password: string): Promise<AuthSession> {
const error = validateLogin(email, password);
if (error) throw new Error(error);
const pb = client();
await pb.collection("users").authWithPassword(email.trim().toLowerCase(), password);
return session(pb);
}
export async function sessionFromToken(token: string): Promise<AuthSession | null> {
if (!token) return null;
const pb = client();
pb.authStore.save(token);
if (!pb.authStore.isValid) return null;
try {
// isValid only checks expiry. The backend verifies the signature and current user.
await pb.collection("users").authRefresh();
} catch (error) {
if (error instanceof ClientResponseError && [400, 401, 403, 404].includes(error.status)) return null;
throw error; // outages fail closed, rather than masquerading as an invalid password
}
try { return await session(pb); } catch (error) {
if (error instanceof InactiveUserError) return null;
throw error;
}
}
export async function changeUserPassword(current: AuthSession, password: string): Promise<AuthSession> {
const error = validateNewPassword(password, password);
if (error) throw new Error(error);
if (current.passwordChanged) throw new Error("The first-login password has already been changed.");
const admin = await adminClient();
await admin.collection("users").update(current.user.id, { password, passwordConfirm: password });
const updated = await authenticateUser(current.user.email, password);
const state = await onboarding(current.user.id);
if (state) {
await admin.collection("user_onboarding").update(state.id, { password_changed: true });
} else {
await admin.collection("user_onboarding").create({ user: current.user.id, password_changed: true });
}
return { ...updated, passwordChanged: true };
}
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();
const user = await pb.collection("users").create({
name: name.trim(), email: email.trim().toLowerCase(),
password: STARTER_PASSWORD, passwordConfirm: STARTER_PASSWORD,
});
if (access !== "full") {
// 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[]> {
const pb = await adminClient();
const [users, states] = await Promise.all([
pb.collection("users").getFullList({ sort: "name,email" }),
pb.collection("user_onboarding").getFullList(),
]);
const byUser = new Map(states.map((state) => [state.user, state]));
return users.map((user) => ({
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),
}));
}
function validateManagedUser(actorId: string, userId: string) {
if (actorId === userId) throw new Error("You can't disable or delete your own account.");
if (!/^[a-z0-9]{15}$/.test(userId)) throw new Error("Choose a valid user.");
}
export async function setUserActive(actorId: string, userId: string, active: boolean): 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, { disabled: !active });
else await pb.collection("user_onboarding").create({ user: userId, disabled: !active, password_changed: false });
}
export async function deleteUser(actorId: string, userId: string): Promise<void> {
validateManagedUser(actorId, userId);
await (await adminClient()).collection("users").delete(userId);
}