- 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>
148 lines
6.8 KiB
TypeScript
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 === "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[]> {
|
|
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);
|
|
}
|