// 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 | undefined; async function adminClient(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { validateManagedUser(actorId, userId); await (await adminClient()).collection("users").delete(userId); }