mega-plant/lib/auth-service.ts
Dave 2594dead84 feat: add user access and improve plant dashboard
Add PocketBase login, first-sign-in password changes, account settings,
and user activation, reactivation and deletion with self-removal guards.
Protect application pages and APIs and keep private pages out of PWA
caches.

Resolve plant locations from site assignments and improve map controls,
profit chart views, navigation and mobile table layouts.

Validation: production build and TypeScript passed; 28 unit tests passed.
Backend access integration tests and mobile verification are documented.
2026-10-06 04:02:08 +02:00

131 lines
5.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, 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 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 };
}
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): Promise<RecordModel> {
const error = validateNewUser(name, email);
if (error) throw new Error(error);
const pb = await adminClient();
return pb.collection("users").create({
name: name.trim(), email: email.trim().toLowerCase(),
password: STARTER_PASSWORD, passwordConfirm: STARTER_PASSWORD,
});
}
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,
}));
}
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);
}